1use colored::Colorize;
9use indicatif::{ProgressBar, ProgressStyle};
10use std::path::Path;
11use std::time::Duration;
12use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
13
14pub 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 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
45pub 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
62pub fn clean_path<P: AsRef<Path>>(path: P) -> String {
64 let s = path.as_ref().display().to_string();
65 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 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
96pub 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
110fn 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
137pub fn print_success(msg: &str) {
139 println!("{} {}", "✓".green().bold(), highlight_code_spans(msg));
140}
141
142pub fn print_warning(msg: &str) {
148 eprintln!("{} {}", "⚠".yellow().bold(), highlight_code_spans(msg));
149}
150
151pub fn print_error(msg: &str) {
153 eprintln!("{} {}", "✗".red().bold(), highlight_code_spans(msg));
154}
155
156pub fn print_info(msg: &str) {
158 println!("{} {}", "→".blue().bold(), highlight_code_spans(msg));
159}
160
161pub fn print_notice(msg: &str) {
168 eprintln!("{} {}", "→".blue().bold(), highlight_code_spans(msg));
169}
170
171pub fn print_header(msg: &str) {
173 println!("\n{}", msg.cyan().bold().underline());
174}
175
176pub fn format_bytes_styled(bytes: u64) -> String {
178 format_bytes(bytes).green().bold().to_string()
179}
180
181pub fn styled_path<P: AsRef<Path>>(path: P) -> String {
183 clean_path(path).cyan().to_string()
184}
185
186pub fn styled_adapter(name: &str) -> String {
189 name.magenta().to_string()
190}
191
192pub fn print_banner() {
194 let art = format!(
195 r#"
196 ___ _____ __ __ ____ ____ _ _ _ _ _____
197| _ \ | ____|\ \ / / | _ \| _ \| | | | \ | | ____|
198| | | || _| \ \ / / | |_) | |_) | | | | \| | _|
199| |_| || |___ \ V / | __/| _ <| |_| | |\ | |___
200|____/ |_____| \_/ |_| |_| \_\\___/|_| \_|_____| v{}
201"#,
202 crate::constants::VERSION
203 );
204 println!("{}", art.truecolor(64, 224, 208).bold());
205}
206
207pub fn print_attribution() {
216 use std::io::IsTerminal;
217 if std::io::stdout().is_terminal() {
218 println!("{}", crate::constants::ATTRIBUTION_LINE.dimmed());
219 }
220}
221
222pub fn plural<'a>(count: usize, one: &'a str, many: &'a str) -> &'a str {
227 if count == 1 { one } else { many }
228}
229
230pub fn format_bytes(bytes: u64) -> String {
232 use humansize::{BINARY, format_size};
233 format_size(bytes, BINARY)
234}
235
236pub fn shared_note(shared_bytes: u64, adapter: &str) -> String {
244 if shared_bytes == 0 {
245 String::new()
246 } else {
247 format!(
248 " (+{} hardlinked into the {adapter} store — not counted, the store keeps them)",
249 format_bytes(shared_bytes)
250 )
251 }
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257
258 #[test]
259 fn test_format_bytes() {
260 assert_eq!(format_bytes(0), "0 B");
261 assert_eq!(format_bytes(1024), "1 KiB");
262 assert_eq!(format_bytes(1024 * 1024), "1 MiB");
263 assert_eq!(format_bytes(1024 * 1024 * 1024), "1 GiB");
264 }
265
266 #[test]
267 fn code_spans_survive_highlighting_verbatim_when_color_is_off() {
268 colored::control::set_override(false);
272 assert_eq!(
273 highlight_code_spans("run `devp setup` again"),
274 "run `devp setup` again"
275 );
276 assert_eq!(highlight_code_spans("no spans here"), "no spans here");
277 assert_eq!(
278 highlight_code_spans("odd `tick remains"),
279 "odd `tick remains"
280 );
281 assert_eq!(
282 highlight_code_spans("`a` and `b`, plus `stray"),
283 "`a` and `b`, plus `stray"
284 );
285 colored::control::unset_override();
286 }
287
288 #[test]
289 fn a_wide_name_is_padded_to_columns_not_to_char_count() {
290 let cjk = "项目目录名称测试";
293 assert_eq!(cjk.chars().count(), 8);
294 assert_eq!(UnicodeWidthStr::width(cjk), 16);
295 let padded = pad_display(cjk, 20);
296 assert_eq!(UnicodeWidthStr::width(padded.as_str()), 20);
297 assert!(padded.ends_with(" "));
298 }
299
300 #[test]
301 fn ascii_padding_still_matches_the_format_specifier_it_replaces() {
302 assert_eq!(pad_display("repo", 10), format!("{:<10}", "repo"));
303 assert_eq!(pad_display("", 3), " ");
304 }
305
306 #[test]
307 fn an_overlong_name_is_truncated_rather_than_pushing_the_next_column() {
308 let long = "a".repeat(50);
309 let out = pad_display(&long, 10);
310 assert_eq!(UnicodeWidthStr::width(out.as_str()), 10);
311 assert!(out.ends_with('…'));
312 }
313
314 #[test]
315 fn a_wide_char_straddling_the_cut_is_dropped_and_the_gap_is_closed() {
316 let out = truncate_display("测试字符", 5);
319 assert_eq!(UnicodeWidthStr::width(out.as_str()), 5);
320 assert!(out.starts_with("测试"));
321 }
322
323 #[test]
324 fn an_emoji_path_component_counts_as_two_columns() {
325 let s = "🚀repo";
326 assert_eq!(UnicodeWidthStr::width(s), 6);
327 assert_eq!(UnicodeWidthStr::width(pad_display(s, 12).as_str()), 12);
328 }
329
330 #[test]
331 fn a_zero_width_column_produces_nothing() {
332 assert_eq!(truncate_display("anything", 0), "");
333 }
334
335 #[test]
336 fn test_clean_path() {
337 assert_eq!(clean_path(r"\\?\C:\Users\krish"), r"C:\Users\krish");
338 assert_eq!(
339 clean_path(r"\\?\UNC\server\share\repo"),
340 r"\\server\share\repo"
341 );
342 assert_eq!(clean_path(r"/private/var/tmp/repo"), r"/var/tmp/repo");
343 assert_eq!(clean_path(r"//server//share//repo"), r"//server/share/repo");
346 assert_eq!(clean_path(r"/home//user///repo"), r"/home/user/repo");
347 }
348}