1use std::collections::HashMap;
4use std::io::IsTerminal;
5use std::path::{Component, Path, PathBuf};
6
7use serde::Serialize;
8
9use crate::graph::{ModuleGraph, ModuleId};
10use crate::query::{CutModule, DiffResult, TraceResult};
11
12#[allow(clippy::fn_params_excessive_bools)]
20pub const fn should_use_color(
21 stream_is_tty: bool,
22 no_color_flag: bool,
23 no_color_env: bool,
24 term_dumb: bool,
25) -> bool {
26 if no_color_flag || no_color_env || term_dumb {
27 return false;
28 }
29 stream_is_tty
30}
31
32const fn plural(n: u64) -> &'static str {
33 if n == 1 { "" } else { "s" }
34}
35
36#[derive(Clone, Copy)]
37struct C {
38 color: bool,
39}
40
41impl C {
42 fn new(no_color: bool) -> Self {
43 Self {
44 color: should_use_color(
45 std::io::stdout().is_terminal(),
46 no_color,
47 std::env::var_os("NO_COLOR").is_some(),
48 std::env::var("TERM").is_ok_and(|v| v == "dumb"),
49 ),
50 }
51 }
52
53 fn bold_green(self, s: &str) -> String {
54 if self.color { format!("\x1b[1;92m{s}\x1b[0m") } else { s.to_string() }
55 }
56
57 fn red(self, s: &str) -> String {
58 if self.color { format!("\x1b[31m{s}\x1b[0m") } else { s.to_string() }
59 }
60
61 fn green(self, s: &str) -> String {
62 if self.color { format!("\x1b[32m{s}\x1b[0m") } else { s.to_string() }
63 }
64
65 fn dim(self, s: &str) -> String {
66 if self.color { format!("\x1b[2m{s}\x1b[0m") } else { s.to_string() }
67 }
68}
69
70#[derive(Debug, Clone, Copy)]
71pub struct StderrColor {
72 color: bool,
73}
74
75impl StderrColor {
76 pub fn new(no_color: bool) -> Self {
77 Self {
78 color: should_use_color(
79 std::io::stderr().is_terminal(),
80 no_color,
81 std::env::var_os("NO_COLOR").is_some(),
82 std::env::var("TERM").is_ok_and(|v| v == "dumb"),
83 ),
84 }
85 }
86
87 pub fn error(self, s: &str) -> String {
88 if self.color { format!("\x1b[1;91m{s}\x1b[0m") } else { s.to_string() }
89 }
90
91 pub fn warning(self, s: &str) -> String {
92 if self.color { format!("\x1b[1;93m{s}\x1b[0m") } else { s.to_string() }
93 }
94
95 pub fn status(self, s: &str) -> String {
96 if self.color { format!("\x1b[1;92m{s}\x1b[0m") } else { s.to_string() }
97 }
98}
99
100#[allow(clippy::cast_precision_loss)]
101pub fn format_size(bytes: u64) -> String {
102 if bytes >= 1_000_000 {
103 format!("{:.1} MB", bytes as f64 / 1_000_000.0)
104 } else if bytes >= 1_000 {
105 format!("{:.0} KB", bytes as f64 / 1_000.0)
106 } else {
107 format!("{bytes} B")
108 }
109}
110
111pub fn relative_path(path: &Path, root: &Path) -> String {
112 path.strip_prefix(root)
113 .unwrap_or(path)
114 .to_string_lossy()
115 .into_owned()
116}
117
118fn display_name(graph: &ModuleGraph, mid: ModuleId, root: &Path) -> String {
119 let m = graph.module(mid);
120 m.package.clone().unwrap_or_else(|| relative_path(&m.path, root))
121}
122
123fn package_relative_path(path: &Path, package_name: &str) -> String {
127 let components: Vec<_> = path.components().collect();
128 if let Some((scope, name)) = package_name.split_once('/') {
133 for (i, pair) in components.windows(2).enumerate().rev() {
134 if let (Component::Normal(a), Component::Normal(b)) = (&pair[0], &pair[1])
135 && a.to_str() == Some(scope) && b.to_str() == Some(name)
136 {
137 let sub: PathBuf = components[i..].iter().collect();
138 return sub.to_string_lossy().into_owned();
139 }
140 }
141 } else {
142 for (i, comp) in components.iter().enumerate().rev() {
143 if let Component::Normal(name) = comp
144 && name.to_str() == Some(package_name)
145 {
146 let sub: PathBuf = components[i..].iter().collect();
147 return sub.to_string_lossy().into_owned();
148 }
149 }
150 }
151 path.file_name()
152 .and_then(|n| n.to_str())
153 .unwrap_or("?")
154 .to_string()
155}
156
157#[derive(Debug, Clone, Copy)]
158pub struct DisplayOpts {
159 pub top: i32,
160 pub top_modules: i32,
161 pub include_dynamic: bool,
162 pub no_color: bool,
163 pub max_weight: Option<u64>,
164}
165
166fn chain_display_names(graph: &ModuleGraph, chain: &[ModuleId], root: &Path) -> Vec<String> {
169 let names: Vec<String> = chain.iter().map(|&mid| display_name(graph, mid, root)).collect();
170 let mut counts: HashMap<&str, usize> = HashMap::new();
171 for name in &names {
172 *counts.entry(name.as_str()).or_default() += 1;
173 }
174 names
175 .iter()
176 .enumerate()
177 .map(|(i, name)| {
178 if counts[name.as_str()] > 1 {
179 let m = graph.module(chain[i]);
180 if let Some(ref pkg) = m.package {
181 return package_relative_path(&m.path, pkg);
182 }
183 }
184 name.clone()
185 })
186 .collect()
187}
188
189#[allow(clippy::cast_sign_loss)]
190pub fn print_trace(graph: &ModuleGraph, result: &TraceResult, entry_path: &Path, root: &Path, opts: &DisplayOpts) {
191 let c = C::new(opts.no_color);
192 println!("{}", relative_path(entry_path, root));
193 let (kind, suffix) = if opts.include_dynamic {
194 ("total", ", static + dynamic")
195 } else {
196 ("static", "")
197 };
198 let weight = format_size(result.static_weight);
199 let modules = format!(
200 "{} module{}{}",
201 result.static_module_count,
202 plural(result.static_module_count as u64),
203 suffix,
204 );
205
206 if let Some(threshold) = opts.max_weight.filter(|&t| result.static_weight > t) {
207 let sc = StderrColor::new(opts.no_color);
208 eprintln!(
209 "{} {kind} transitive weight {weight} ({modules}) exceeds --max-weight threshold {}",
210 sc.error("error:"),
211 format_size(threshold),
212 );
213 } else {
214 let label = if opts.include_dynamic {
215 "Total transitive weight:"
216 } else {
217 "Static transitive weight:"
218 };
219 println!("{} {weight} ({modules})", c.bold_green(label));
220 }
221
222 if !opts.include_dynamic && result.dynamic_only_module_count > 0 {
223 println!(
224 "{} {} ({} module{}, not loaded at startup)",
225 c.bold_green("Dynamic-only weight:"),
226 format_size(result.dynamic_only_weight),
227 result.dynamic_only_module_count,
228 plural(result.dynamic_only_module_count as u64)
229 );
230 }
231
232 if opts.top != 0 {
233 println!();
234 let deps_label = if opts.include_dynamic { "Heavy dependencies (static + dynamic):" } else { "Heavy dependencies (static):" };
235 println!("{}", c.bold_green(deps_label));
236 if result.heavy_packages.is_empty() {
237 println!(" (none \u{2014} all reachable modules are first-party)");
238 } else {
239 for pkg in &result.heavy_packages {
240 println!(
241 " {:<35} {} {} file{}",
242 pkg.name,
243 format_size(pkg.total_size),
244 pkg.file_count,
245 plural(u64::from(pkg.file_count))
246 );
247 if pkg.chain.len() > 1 {
248 let chain_str = chain_display_names(graph, &pkg.chain, root);
249 println!(" -> {}", chain_str.join(" -> "));
250 }
251 }
252 }
253 println!();
254 }
255
256 if opts.top_modules != 0 && !result.modules_by_cost.is_empty() {
257 println!("{}", c.bold_green("Modules (sorted by exclusive weight):"));
258 let display_count = if opts.top_modules < 0 {
259 result.modules_by_cost.len()
260 } else {
261 result.modules_by_cost.len().min(opts.top_modules as usize)
262 };
263 for mc in &result.modules_by_cost[..display_count] {
264 let m = graph.module(mc.module_id);
265 println!(
266 " {:<55} {}",
267 relative_path(&m.path, root),
268 format_size(mc.exclusive_size)
269 );
270 }
271 if result.modules_by_cost.len() > display_count {
272 let remaining = result.modules_by_cost.len() - display_count;
273 println!(" ... and {remaining} more module{}", plural(remaining as u64));
274 }
275 }
276}
277
278#[allow(clippy::cast_sign_loss)]
279pub fn print_diff(diff: &DiffResult, entry_a: &str, entry_b: &str, limit: i32, no_color: bool) {
280 let c = C::new(no_color);
281 println!("Diff: {entry_a} vs {entry_b}");
282 println!();
283 println!(
284 " {:<40} {}",
285 entry_a,
286 format_size(diff.entry_a_weight)
287 );
288 println!(
289 " {:<40} {}",
290 entry_b,
291 format_size(diff.entry_b_weight)
292 );
293 let sign = if diff.weight_delta >= 0 { "+" } else { "-" };
294 println!(
295 " {:<40} {sign}{}",
296 "Delta",
297 format_size(diff.weight_delta.unsigned_abs())
298 );
299
300 let has_dynamic = diff.dynamic_a_weight > 0 || diff.dynamic_b_weight > 0;
301 if has_dynamic {
302 println!();
303 println!(
304 " {:<40} {}",
305 "Dynamic-only (before)",
306 format_size(diff.dynamic_a_weight)
307 );
308 println!(
309 " {:<40} {}",
310 "Dynamic-only (after)",
311 format_size(diff.dynamic_b_weight)
312 );
313 let dyn_sign = if diff.dynamic_weight_delta >= 0 { "+" } else { "-" };
314 println!(
315 " {:<40} {dyn_sign}{}",
316 "Dynamic delta",
317 format_size(diff.dynamic_weight_delta.unsigned_abs())
318 );
319 }
320
321 println!();
322
323 if !diff.only_in_a.is_empty() {
324 let show = if limit < 0 { diff.only_in_a.len() } else { diff.only_in_a.len().min(limit as usize) };
325 println!("{}", c.red(&format!("Only in {entry_a}:")));
326 for pkg in &diff.only_in_a[..show] {
327 println!("{}", c.red(&format!(" - {:<35} {}", pkg.name, format_size(pkg.size))));
328 }
329 let remaining = diff.only_in_a.len() - show;
330 if remaining > 0 {
331 println!("{}", c.dim(&format!(" - ... and {remaining} more")));
332 }
333 }
334 if !diff.only_in_b.is_empty() {
335 let show = if limit < 0 { diff.only_in_b.len() } else { diff.only_in_b.len().min(limit as usize) };
336 println!("{}", c.green(&format!("Only in {entry_b}:")));
337 for pkg in &diff.only_in_b[..show] {
338 println!("{}", c.green(&format!(" + {:<35} {}", pkg.name, format_size(pkg.size))));
339 }
340 let remaining = diff.only_in_b.len() - show;
341 if remaining > 0 {
342 println!("{}", c.dim(&format!(" + ... and {remaining} more")));
343 }
344 }
345
346 if !diff.dynamic_only_in_a.is_empty() {
347 let show = if limit < 0 { diff.dynamic_only_in_a.len() } else { diff.dynamic_only_in_a.len().min(limit as usize) };
348 println!("{}", c.red(&format!("Dynamic only in {entry_a}:")));
349 for pkg in &diff.dynamic_only_in_a[..show] {
350 println!("{}", c.red(&format!(" - {:<35} {}", pkg.name, format_size(pkg.size))));
351 }
352 let remaining = diff.dynamic_only_in_a.len() - show;
353 if remaining > 0 {
354 println!("{}", c.dim(&format!(" - ... and {remaining} more")));
355 }
356 }
357 if !diff.dynamic_only_in_b.is_empty() {
358 let show = if limit < 0 { diff.dynamic_only_in_b.len() } else { diff.dynamic_only_in_b.len().min(limit as usize) };
359 println!("{}", c.green(&format!("Dynamic only in {entry_b}:")));
360 for pkg in &diff.dynamic_only_in_b[..show] {
361 println!("{}", c.green(&format!(" + {:<35} {}", pkg.name, format_size(pkg.size))));
362 }
363 let remaining = diff.dynamic_only_in_b.len() - show;
364 if remaining > 0 {
365 println!("{}", c.dim(&format!(" + ... and {remaining} more")));
366 }
367 }
368
369 if diff.shared_count > 0 {
370 println!(
371 "{}", c.dim(&format!(
372 "Shared: {} package{}",
373 diff.shared_count,
374 if diff.shared_count == 1 { "" } else { "s" }
375 ))
376 );
377 }
378}
379
380pub fn print_chains(
381 graph: &ModuleGraph,
382 chains: &[Vec<ModuleId>],
383 target_label: &str,
384 root: &Path,
385 target_exists: bool,
386 no_color: bool,
387) {
388 let c = C::new(no_color);
389 if chains.is_empty() {
390 if target_exists {
391 println!("\"{target_label}\" exists in the graph but is not reachable from this entry point.");
392 } else {
393 println!("\"{target_label}\" is not in the dependency graph. Check the spelling or verify it's installed.");
394 }
395 return;
396 }
397 let hops = chains[0].len().saturating_sub(1);
398 println!(
399 "{}\n",
400 c.bold_green(&format!(
401 "{} chain{} to \"{}\" ({} hop{}):",
402 chains.len(),
403 if chains.len() == 1 { "" } else { "s" },
404 target_label,
405 hops,
406 if hops == 1 { "" } else { "s" },
407 )),
408 );
409 for (i, chain) in chains.iter().enumerate() {
410 let chain_str = chain_display_names(graph, chain, root);
411 println!(" {}. {}", i + 1, chain_str.join(" -> "));
412 }
413}
414
415pub fn print_chains_json(
416 graph: &ModuleGraph,
417 chains: &[Vec<ModuleId>],
418 target_label: &str,
419 root: &Path,
420 target_exists: bool,
421) {
422 if chains.is_empty() {
423 let json = JsonChainsEmpty {
424 target: target_label.to_string(),
425 found_in_graph: target_exists,
426 chain_count: 0,
427 chains: Vec::new(),
428 };
429 println!("{}", serde_json::to_string_pretty(&json).unwrap());
430 return;
431 }
432 let json = JsonChains {
433 target: target_label.to_string(),
434 chain_count: chains.len(),
435 hop_count: chains.first().map_or(0, |c| c.len().saturating_sub(1)),
436 chains: chains
437 .iter()
438 .map(|chain| chain_display_names(graph, chain, root))
439 .collect(),
440 };
441 println!("{}", serde_json::to_string_pretty(&json).unwrap());
442}
443
444pub fn print_cut(
445 graph: &ModuleGraph,
446 cuts: &[CutModule],
447 chains: &[Vec<ModuleId>],
448 target_label: &str,
449 root: &Path,
450 target_exists: bool,
451 no_color: bool,
452) {
453 let c = C::new(no_color);
454 if chains.is_empty() {
455 if target_exists {
456 println!("\"{target_label}\" exists in the graph but is not reachable from this entry point.");
457 } else {
458 println!("\"{target_label}\" is not in the dependency graph. Check the spelling or verify it's installed.");
459 }
460 return;
461 }
462
463 if cuts.is_empty() {
464 let is_direct = chains.iter().all(|c| c.len() == 2);
465 if is_direct {
466 println!(
467 "Entry file directly imports \"{target_label}\" \u{2014} remove the import to sever the dependency."
468 );
469 } else {
470 println!(
471 "No single cut point can sever all {} chain{} to \"{target_label}\".",
472 chains.len(),
473 if chains.len() == 1 { "" } else { "s" },
474 );
475 println!("Each chain takes a different path \u{2014} multiple fixes needed.");
476 }
477 return;
478 }
479
480 println!(
481 "{}\n",
482 c.bold_green(&format!(
483 "{} cut point{} to sever all {} chain{} to \"{}\":",
484 cuts.len(),
485 if cuts.len() == 1 { "" } else { "s" },
486 chains.len(),
487 if chains.len() == 1 { "" } else { "s" },
488 target_label,
489 )),
490 );
491 for cut in cuts {
492 if chains.len() == 1 {
493 println!(
494 " {:<45} {:>8}",
495 display_name(graph, cut.module_id, root),
496 format_size(cut.exclusive_size),
497 );
498 } else {
499 println!(
500 " {:<45} {:>8} (breaks {}/{} chains)",
501 display_name(graph, cut.module_id, root),
502 format_size(cut.exclusive_size),
503 cut.chains_broken,
504 chains.len()
505 );
506 }
507 }
508}
509
510pub fn print_cut_json(
511 graph: &ModuleGraph,
512 cuts: &[CutModule],
513 chains: &[Vec<ModuleId>],
514 target_label: &str,
515 root: &Path,
516 target_exists: bool,
517) {
518 if chains.is_empty() {
519 let json = JsonChainsEmpty {
520 target: target_label.to_string(),
521 found_in_graph: target_exists,
522 chain_count: 0,
523 chains: Vec::new(),
524 };
525 println!("{}", serde_json::to_string_pretty(&json).unwrap());
526 return;
527 }
528
529 let json = JsonCut {
530 target: target_label.to_string(),
531 chain_count: chains.len(),
532 direct_import: cuts.is_empty() && chains.iter().all(|c| c.len() == 2),
533 cut_points: cuts
534 .iter()
535 .map(|c| JsonCutPoint {
536 module: display_name(graph, c.module_id, root),
537 exclusive_size_bytes: c.exclusive_size,
538 chains_broken: c.chains_broken,
539 })
540 .collect(),
541 };
542 println!("{}", serde_json::to_string_pretty(&json).unwrap());
543}
544
545#[allow(clippy::cast_sign_loss)]
546pub fn print_packages(graph: &ModuleGraph, top: i32, no_color: bool) {
547 let c = C::new(no_color);
548 let mut packages: Vec<_> = graph.package_map.values().collect();
549 packages.sort_by(|a, b| b.total_reachable_size.cmp(&a.total_reachable_size));
550
551 if packages.is_empty() {
552 println!("No third-party packages found in the dependency graph.");
553 return;
554 }
555
556 let total = packages.len();
557 let display_count = if top < 0 { total } else { total.min(top as usize) };
558
559 println!("{}\n", c.bold_green(&format!("{} package{}:", total, plural(total as u64))));
560 for pkg in &packages[..display_count] {
561 println!(
562 " {:<40} {:>8} {} file{}",
563 pkg.name,
564 format_size(pkg.total_reachable_size),
565 pkg.total_reachable_files,
566 plural(u64::from(pkg.total_reachable_files))
567 );
568 }
569 if total > display_count {
570 let remaining = total - display_count;
571 println!(" ... and {remaining} more package{}", plural(remaining as u64));
572 }
573}
574
575#[allow(clippy::cast_sign_loss)]
576pub fn print_packages_json(graph: &ModuleGraph, top: i32) {
577 let mut packages: Vec<_> = graph.package_map.values().collect();
578 packages.sort_by(|a, b| b.total_reachable_size.cmp(&a.total_reachable_size));
579
580 let total = packages.len();
581 let display_count = if top < 0 { total } else { total.min(top as usize) };
582
583 let json_packages: Vec<serde_json::Value> = packages[..display_count]
584 .iter()
585 .map(|pkg| serde_json::json!({
586 "name": pkg.name,
587 "size": pkg.total_reachable_size,
588 "files": pkg.total_reachable_files,
589 }))
590 .collect();
591
592 let output = serde_json::json!({
593 "package_count": total,
594 "packages": json_packages,
595 });
596 println!("{}", serde_json::to_string_pretty(&output).unwrap());
597}
598
599#[derive(Serialize)]
602struct JsonCut {
603 target: String,
604 chain_count: usize,
605 direct_import: bool,
606 cut_points: Vec<JsonCutPoint>,
607}
608
609#[derive(Serialize)]
610struct JsonCutPoint {
611 module: String,
612 exclusive_size_bytes: u64,
613 chains_broken: usize,
614}
615
616#[derive(Serialize)]
617struct JsonChains {
618 target: String,
619 chain_count: usize,
620 hop_count: usize,
621 chains: Vec<Vec<String>>,
622}
623
624#[derive(Serialize)]
625struct JsonChainsEmpty {
626 target: String,
627 found_in_graph: bool,
628 chain_count: usize,
629 chains: Vec<Vec<String>>,
630}
631
632#[derive(Serialize)]
633struct JsonTrace {
634 entry: String,
635 static_weight_bytes: u64,
636 static_module_count: usize,
637 dynamic_only_weight_bytes: u64,
638 dynamic_only_module_count: usize,
639 heavy_packages: Vec<JsonPackage>,
640 modules_by_cost: Vec<JsonModuleCost>,
641}
642
643#[derive(Serialize)]
644struct JsonPackage {
645 name: String,
646 total_size_bytes: u64,
647 file_count: u32,
648 chain: Vec<String>,
649}
650
651#[derive(Serialize)]
652struct JsonModuleCost {
653 path: String,
654 exclusive_size_bytes: u64,
655}
656
657#[allow(clippy::cast_sign_loss)]
658pub fn print_trace_json(
659 graph: &ModuleGraph,
660 result: &TraceResult,
661 entry_path: &Path,
662 root: &Path,
663 top_modules: i32,
664) {
665 let json = JsonTrace {
666 entry: relative_path(entry_path, root),
667 static_weight_bytes: result.static_weight,
668 static_module_count: result.static_module_count,
669 dynamic_only_weight_bytes: result.dynamic_only_weight,
670 dynamic_only_module_count: result.dynamic_only_module_count,
671 heavy_packages: result
672 .heavy_packages
673 .iter()
674 .map(|pkg| JsonPackage {
675 name: pkg.name.clone(),
676 total_size_bytes: pkg.total_size,
677 file_count: pkg.file_count,
678 chain: chain_display_names(graph, &pkg.chain, root),
679 })
680 .collect(),
681 modules_by_cost: {
682 let limit = if top_modules < 0 {
683 result.modules_by_cost.len()
684 } else {
685 result.modules_by_cost.len().min(top_modules as usize)
686 };
687 result.modules_by_cost[..limit]
688 .iter()
689 .map(|mc| {
690 let m = graph.module(mc.module_id);
691 JsonModuleCost {
692 path: relative_path(&m.path, root),
693 exclusive_size_bytes: mc.exclusive_size,
694 }
695 })
696 .collect()
697 },
698 };
699
700 println!("{}", serde_json::to_string_pretty(&json).unwrap());
701}
702
703#[cfg(test)]
704mod tests {
705 use super::*;
706
707 #[test]
708 fn color_enabled_when_tty_and_no_overrides() {
709 assert!(should_use_color(true, false, false, false));
710 }
711
712 #[test]
713 fn color_disabled_when_not_tty() {
714 assert!(!should_use_color(false, false, false, false));
715 }
716
717 #[test]
718 fn color_disabled_by_flag() {
719 assert!(!should_use_color(true, true, false, false));
720 }
721
722 #[test]
723 fn color_disabled_by_no_color_env() {
724 assert!(!should_use_color(true, false, true, false));
725 }
726
727 #[test]
728 fn color_disabled_by_term_dumb() {
729 assert!(!should_use_color(true, false, false, true));
730 }
731
732 #[test]
733 fn package_relative_path_pnpm_store() {
734 let path = PathBuf::from("/dev/cloudflare/workers-sdk/node_modules/.pnpm/cloudflare@5.2.0/node_modules/cloudflare/index.js");
736 assert_eq!(
737 package_relative_path(&path, "cloudflare"),
738 "cloudflare/index.js"
739 );
740 }
741
742 #[test]
743 fn package_relative_path_scoped_pnpm() {
744 let path = PathBuf::from("/project/node_modules/.pnpm/@babel+parser@7.25.0/node_modules/@babel/parser/lib/index.js");
745 assert_eq!(
746 package_relative_path(&path, "@babel/parser"),
747 "@babel/parser/lib/index.js"
748 );
749 }
750
751 #[test]
752 fn package_relative_path_simple() {
753 let path = PathBuf::from("/project/node_modules/lodash/fp/map.js");
755 assert_eq!(
756 package_relative_path(&path, "lodash"),
757 "lodash/fp/map.js"
758 );
759 }
760}