1use std::collections::HashMap;
4use std::fmt::Write as _;
5use std::io::IsTerminal;
6use std::path::{Component, Path, PathBuf};
7
8use serde::Serialize;
9
10use crate::graph::{ModuleGraph, ModuleId};
11use crate::query::DiffResult;
12
13pub const DEFAULT_TOP: i32 = 10;
15pub const DEFAULT_TOP_MODULES: i32 = 20;
17
18#[allow(clippy::fn_params_excessive_bools)]
26pub const fn should_use_color(
27 stream_is_tty: bool,
28 no_color_flag: bool,
29 no_color_env: bool,
30 term_dumb: bool,
31) -> bool {
32 if no_color_flag || no_color_env || term_dumb {
33 return false;
34 }
35 stream_is_tty
36}
37
38const fn plural(n: u64) -> &'static str {
39 if n == 1 { "" } else { "s" }
40}
41
42#[derive(Clone, Copy)]
43struct C {
44 color: bool,
45}
46
47impl C {
48 fn bold_green(self, s: &str) -> String {
49 if self.color {
50 format!("\x1b[1;92m{s}\x1b[0m")
51 } else {
52 s.to_string()
53 }
54 }
55
56 fn red(self, s: &str) -> String {
57 if self.color {
58 format!("\x1b[31m{s}\x1b[0m")
59 } else {
60 s.to_string()
61 }
62 }
63
64 fn green(self, s: &str) -> String {
65 if self.color {
66 format!("\x1b[32m{s}\x1b[0m")
67 } else {
68 s.to_string()
69 }
70 }
71
72 fn dim(self, s: &str) -> String {
73 if self.color {
74 format!("\x1b[2m{s}\x1b[0m")
75 } else {
76 s.to_string()
77 }
78 }
79}
80
81#[derive(Debug, Clone, Copy)]
82pub struct StderrColor {
83 color: bool,
84}
85
86impl StderrColor {
87 pub fn new(no_color: bool) -> Self {
88 Self {
89 color: should_use_color(
90 std::io::stderr().is_terminal(),
91 no_color,
92 std::env::var_os("NO_COLOR").is_some(),
93 std::env::var("TERM").is_ok_and(|v| v == "dumb"),
94 ),
95 }
96 }
97
98 pub fn error(self, s: &str) -> String {
99 if self.color {
100 format!("\x1b[1;91m{s}\x1b[0m")
101 } else {
102 s.to_string()
103 }
104 }
105
106 pub fn warning(self, s: &str) -> String {
107 if self.color {
108 format!("\x1b[1;93m{s}\x1b[0m")
109 } else {
110 s.to_string()
111 }
112 }
113
114 pub fn status(self, s: &str) -> String {
115 if self.color {
116 format!("\x1b[1;92m{s}\x1b[0m")
117 } else {
118 s.to_string()
119 }
120 }
121}
122
123#[allow(clippy::too_many_arguments)]
128pub fn print_load_status(
129 from_cache: bool,
130 module_count: usize,
131 elapsed_ms: f64,
132 file_warnings: &[String],
133 unresolvable_dynamic_count: usize,
134 unresolvable_dynamic_files: &[(PathBuf, usize)],
135 root: &Path,
136 sc: StderrColor,
137) {
138 eprintln!(
139 "{} ({module_count} modules) in {elapsed_ms:.1}ms",
140 sc.status(if from_cache {
141 "Loaded cached graph"
142 } else {
143 "Built graph"
144 }),
145 );
146 for w in file_warnings {
147 eprintln!("{} {w}", sc.warning("warning:"));
148 }
149 if unresolvable_dynamic_count > 0 {
150 let n = unresolvable_dynamic_count;
151 eprintln!(
152 "{} {n} dynamic import{} with non-literal argument{} could not be traced:",
153 sc.warning("warning:"),
154 if n == 1 { "" } else { "s" },
155 if n == 1 { "" } else { "s" },
156 );
157 let mut files: Vec<_> = unresolvable_dynamic_files.to_vec();
158 files.sort_by(|a, b| a.0.cmp(&b.0));
159 for (path, file_count) in &files {
160 let rel = relative_path(path, root);
161 eprintln!(" {rel} ({file_count})");
162 }
163 }
164}
165
166#[allow(clippy::cast_precision_loss)]
167pub fn format_size(bytes: u64) -> String {
168 if bytes >= 1_000_000 {
169 format!("{:.1} MB", bytes as f64 / 1_000_000.0)
170 } else if bytes >= 1_000 {
171 format!("{:.0} KB", bytes as f64 / 1_000.0)
172 } else {
173 format!("{bytes} B")
174 }
175}
176
177pub fn relative_path(path: &Path, root: &Path) -> String {
178 path.strip_prefix(root)
179 .unwrap_or(path)
180 .to_string_lossy()
181 .into_owned()
182}
183
184pub(crate) fn display_name(graph: &ModuleGraph, mid: ModuleId, root: &Path) -> String {
185 let m = graph.module(mid);
186 m.package
187 .clone()
188 .unwrap_or_else(|| relative_path(&m.path, root))
189}
190
191fn package_relative_path(path: &Path, package_name: &str) -> String {
195 let components: Vec<_> = path.components().collect();
196 if let Some((scope, name)) = package_name.split_once('/') {
201 for (i, pair) in components.windows(2).enumerate().rev() {
202 if let (Component::Normal(a), Component::Normal(b)) = (&pair[0], &pair[1])
203 && a.to_str() == Some(scope)
204 && b.to_str() == Some(name)
205 {
206 let sub: PathBuf = components[i..].iter().collect();
207 return sub.to_string_lossy().into_owned();
208 }
209 }
210 } else {
211 for (i, comp) in components.iter().enumerate().rev() {
212 if let Component::Normal(name) = comp
213 && name.to_str() == Some(package_name)
214 {
215 let sub: PathBuf = components[i..].iter().collect();
216 return sub.to_string_lossy().into_owned();
217 }
218 }
219 }
220 path.file_name()
221 .and_then(|n| n.to_str())
222 .unwrap_or("?")
223 .to_string()
224}
225
226pub(crate) fn chain_display_names(
229 graph: &ModuleGraph,
230 chain: &[ModuleId],
231 root: &Path,
232) -> Vec<String> {
233 let names: Vec<String> = chain
234 .iter()
235 .map(|&mid| display_name(graph, mid, root))
236 .collect();
237 let mut counts: HashMap<&str, usize> = HashMap::new();
238 for name in &names {
239 *counts.entry(name.as_str()).or_default() += 1;
240 }
241 names
242 .iter()
243 .enumerate()
244 .map(|(i, name)| {
245 if counts[name.as_str()] > 1 {
246 let m = graph.module(chain[i]);
247 if let Some(ref pkg) = m.package {
248 return package_relative_path(&m.path, pkg);
249 }
250 }
251 name.clone()
252 })
253 .collect()
254}
255
256#[derive(Debug, Clone, Serialize)]
262pub struct TraceReport {
263 pub entry: String,
264 pub static_weight_bytes: u64,
265 pub static_module_count: usize,
266 pub dynamic_only_weight_bytes: u64,
267 pub dynamic_only_module_count: usize,
268 pub heavy_packages: Vec<PackageEntry>,
269 pub modules_by_cost: Vec<ModuleEntry>,
270 pub total_modules_with_cost: usize,
272 #[serde(skip)]
274 pub include_dynamic: bool,
275 #[serde(skip)]
277 pub top: i32,
278}
279
280#[derive(Debug, Clone, Serialize)]
281pub struct PackageEntry {
282 pub name: String,
283 pub total_size_bytes: u64,
284 pub file_count: u32,
285 pub chain: Vec<String>,
286}
287
288#[derive(Debug, Clone, Serialize)]
289pub struct ModuleEntry {
290 pub path: String,
291 pub exclusive_size_bytes: u64,
292}
293
294#[derive(Debug, Clone, Serialize)]
296pub struct ChainReport {
297 pub target: String,
298 pub found_in_graph: bool,
299 pub chain_count: usize,
300 pub hop_count: usize,
301 pub chains: Vec<Vec<String>>,
302}
303
304#[derive(Debug, Clone, Serialize)]
306pub struct CutReport {
307 pub target: String,
308 pub found_in_graph: bool,
309 pub chain_count: usize,
310 pub direct_import: bool,
311 pub cut_points: Vec<CutEntry>,
312}
313
314#[derive(Debug, Clone, Serialize)]
315pub struct CutEntry {
316 pub module: String,
317 pub exclusive_size_bytes: u64,
318 pub chains_broken: usize,
319}
320
321#[derive(Debug, Clone, Serialize)]
324pub struct DiffReport {
325 pub entry_a: String,
326 pub entry_b: String,
327 pub weight_a: u64,
328 pub weight_b: u64,
329 pub weight_delta: i64,
330 pub dynamic_weight_a: u64,
331 pub dynamic_weight_b: u64,
332 pub dynamic_weight_delta: i64,
333 pub shared_count: usize,
334 pub only_in_a: Vec<DiffPackageEntry>,
335 pub only_in_b: Vec<DiffPackageEntry>,
336 pub dynamic_only_in_a: Vec<DiffPackageEntry>,
337 pub dynamic_only_in_b: Vec<DiffPackageEntry>,
338 #[serde(skip)]
340 pub limit: i32,
341}
342
343#[derive(Debug, Clone, Serialize)]
344pub struct DiffPackageEntry {
345 pub name: String,
346 pub size: u64,
347}
348
349#[derive(Debug, Clone, Serialize)]
351pub struct PackagesReport {
352 pub package_count: usize,
353 pub packages: Vec<PackageListEntry>,
354}
355
356#[derive(Debug, Clone, Serialize)]
357pub struct PackageListEntry {
358 pub name: String,
359 pub total_size_bytes: u64,
360 pub file_count: u32,
361}
362
363impl TraceReport {
368 pub fn to_json(&self) -> String {
369 serde_json::to_string_pretty(self).unwrap()
370 }
371
372 pub fn to_terminal(&self, color: bool) -> String {
373 let c = C { color };
374 let mut out = String::new();
375 writeln!(out, "{}", self.entry).unwrap();
376
377 let suffix = if self.include_dynamic {
378 ", static + dynamic"
379 } else {
380 ""
381 };
382 let weight = format_size(self.static_weight_bytes);
383 let modules = format!(
384 "{} module{}{}",
385 self.static_module_count,
386 plural(self.static_module_count as u64),
387 suffix,
388 );
389 let label = if self.include_dynamic {
390 "Total transitive weight:"
391 } else {
392 "Static transitive weight:"
393 };
394 writeln!(out, "{} {weight} ({modules})", c.bold_green(label)).unwrap();
395
396 if !self.include_dynamic && self.dynamic_only_module_count > 0 {
397 writeln!(
398 out,
399 "{} {} ({} module{}, not loaded at startup)",
400 c.bold_green("Dynamic-only weight:"),
401 format_size(self.dynamic_only_weight_bytes),
402 self.dynamic_only_module_count,
403 plural(self.dynamic_only_module_count as u64)
404 )
405 .unwrap();
406 }
407
408 if self.top != 0 {
409 writeln!(out).unwrap();
410 let deps_label = if self.include_dynamic {
411 "Heavy dependencies (static + dynamic):"
412 } else {
413 "Heavy dependencies (static):"
414 };
415 writeln!(out, "{}", c.bold_green(deps_label)).unwrap();
416 if self.heavy_packages.is_empty() {
417 writeln!(
418 out,
419 " (none \u{2014} all reachable modules are first-party)"
420 )
421 .unwrap();
422 } else {
423 for pkg in &self.heavy_packages {
424 writeln!(
425 out,
426 " {:<35} {} {} file{}",
427 pkg.name,
428 format_size(pkg.total_size_bytes),
429 pkg.file_count,
430 plural(u64::from(pkg.file_count))
431 )
432 .unwrap();
433 if pkg.chain.len() > 1 {
434 writeln!(out, " -> {}", pkg.chain.join(" -> ")).unwrap();
435 }
436 }
437 }
438 writeln!(out).unwrap();
439 }
440
441 if !self.modules_by_cost.is_empty() {
442 writeln!(
443 out,
444 "{}",
445 c.bold_green("Modules (sorted by exclusive weight):")
446 )
447 .unwrap();
448 for mc in &self.modules_by_cost {
449 writeln!(
450 out,
451 " {:<55} {}",
452 mc.path,
453 format_size(mc.exclusive_size_bytes)
454 )
455 .unwrap();
456 }
457 if self.total_modules_with_cost > self.modules_by_cost.len() {
458 let remaining = self.total_modules_with_cost - self.modules_by_cost.len();
459 writeln!(
460 out,
461 " ... and {remaining} more module{}",
462 plural(remaining as u64)
463 )
464 .unwrap();
465 }
466 }
467
468 out
469 }
470}
471
472impl ChainReport {
473 pub fn to_json(&self) -> String {
474 serde_json::to_string_pretty(self).unwrap()
475 }
476
477 pub fn to_terminal(&self, color: bool) -> String {
478 let c = C { color };
479 let mut out = String::new();
480
481 if self.chains.is_empty() {
482 if self.found_in_graph {
483 writeln!(
484 out,
485 "\"{}\" exists in the graph but is not reachable from this entry point.",
486 self.target
487 )
488 .unwrap();
489 } else {
490 writeln!(
491 out,
492 "\"{}\" is not in the dependency graph. Check the spelling or verify it's installed.",
493 self.target
494 )
495 .unwrap();
496 }
497 return out;
498 }
499
500 writeln!(
501 out,
502 "{}\n",
503 c.bold_green(&format!(
504 "{} chain{} to \"{}\" ({} hop{}):",
505 self.chain_count,
506 if self.chain_count == 1 { "" } else { "s" },
507 self.target,
508 self.hop_count,
509 if self.hop_count == 1 { "" } else { "s" },
510 )),
511 )
512 .unwrap();
513 for (i, chain) in self.chains.iter().enumerate() {
514 writeln!(out, " {}. {}", i + 1, chain.join(" -> ")).unwrap();
515 }
516
517 out
518 }
519}
520
521impl CutReport {
522 pub fn to_json(&self) -> String {
523 serde_json::to_string_pretty(self).unwrap()
524 }
525
526 pub fn to_terminal(&self, color: bool) -> String {
527 let c = C { color };
528 let mut out = String::new();
529
530 if self.chain_count == 0 {
531 if self.found_in_graph {
532 writeln!(
533 out,
534 "\"{}\" exists in the graph but is not reachable from this entry point.",
535 self.target
536 )
537 .unwrap();
538 } else {
539 writeln!(
540 out,
541 "\"{}\" is not in the dependency graph. Check the spelling or verify it's installed.",
542 self.target
543 )
544 .unwrap();
545 }
546 return out;
547 }
548
549 if self.cut_points.is_empty() {
550 if self.direct_import {
551 writeln!(
552 out,
553 "Entry file directly imports \"{}\" \u{2014} remove the import to sever the dependency.",
554 self.target
555 )
556 .unwrap();
557 } else {
558 writeln!(
559 out,
560 "No single cut point can sever all {} chain{} to \"{}\".",
561 self.chain_count,
562 if self.chain_count == 1 { "" } else { "s" },
563 self.target,
564 )
565 .unwrap();
566 writeln!(
567 out,
568 "Each chain takes a different path \u{2014} multiple fixes needed."
569 )
570 .unwrap();
571 }
572 return out;
573 }
574
575 writeln!(
576 out,
577 "{}\n",
578 c.bold_green(&format!(
579 "{} cut point{} to sever all {} chain{} to \"{}\":",
580 self.cut_points.len(),
581 if self.cut_points.len() == 1 { "" } else { "s" },
582 self.chain_count,
583 if self.chain_count == 1 { "" } else { "s" },
584 self.target,
585 )),
586 )
587 .unwrap();
588 for cut in &self.cut_points {
589 if self.chain_count == 1 {
590 writeln!(
591 out,
592 " {:<45} {:>8}",
593 cut.module,
594 format_size(cut.exclusive_size_bytes),
595 )
596 .unwrap();
597 } else {
598 writeln!(
599 out,
600 " {:<45} {:>8} (breaks {}/{} chains)",
601 cut.module,
602 format_size(cut.exclusive_size_bytes),
603 cut.chains_broken,
604 self.chain_count
605 )
606 .unwrap();
607 }
608 }
609
610 out
611 }
612}
613
614impl DiffReport {
615 pub fn to_json(&self) -> String {
616 serde_json::to_string_pretty(self).unwrap()
617 }
618
619 pub fn from_diff(diff: &DiffResult, entry_a: &str, entry_b: &str, limit: i32) -> Self {
620 Self {
621 entry_a: entry_a.to_string(),
622 entry_b: entry_b.to_string(),
623 weight_a: diff.entry_a_weight,
624 weight_b: diff.entry_b_weight,
625 weight_delta: diff.weight_delta,
626 dynamic_weight_a: diff.dynamic_a_weight,
627 dynamic_weight_b: diff.dynamic_b_weight,
628 dynamic_weight_delta: diff.dynamic_weight_delta,
629 shared_count: diff.shared_count,
630 only_in_a: diff
631 .only_in_a
632 .iter()
633 .map(|p| DiffPackageEntry {
634 name: p.name.clone(),
635 size: p.size,
636 })
637 .collect(),
638 only_in_b: diff
639 .only_in_b
640 .iter()
641 .map(|p| DiffPackageEntry {
642 name: p.name.clone(),
643 size: p.size,
644 })
645 .collect(),
646 dynamic_only_in_a: diff
647 .dynamic_only_in_a
648 .iter()
649 .map(|p| DiffPackageEntry {
650 name: p.name.clone(),
651 size: p.size,
652 })
653 .collect(),
654 dynamic_only_in_b: diff
655 .dynamic_only_in_b
656 .iter()
657 .map(|p| DiffPackageEntry {
658 name: p.name.clone(),
659 size: p.size,
660 })
661 .collect(),
662 limit,
663 }
664 }
665
666 #[allow(clippy::cast_sign_loss, clippy::too_many_lines)]
667 pub fn to_terminal(&self, color: bool) -> String {
668 let c = C { color };
669 let mut out = String::new();
670 writeln!(out, "Diff: {} vs {}", self.entry_a, self.entry_b).unwrap();
671 writeln!(out).unwrap();
672 writeln!(out, " {:<40} {}", self.entry_a, format_size(self.weight_a)).unwrap();
673 writeln!(out, " {:<40} {}", self.entry_b, format_size(self.weight_b)).unwrap();
674 let sign = if self.weight_delta >= 0 { "+" } else { "-" };
675 writeln!(
676 out,
677 " {:<40} {sign}{}",
678 "Delta",
679 format_size(self.weight_delta.unsigned_abs())
680 )
681 .unwrap();
682
683 let has_dynamic = self.dynamic_weight_a > 0 || self.dynamic_weight_b > 0;
684 if has_dynamic {
685 writeln!(out).unwrap();
686 writeln!(
687 out,
688 " {:<40} {}",
689 "Dynamic-only (before)",
690 format_size(self.dynamic_weight_a)
691 )
692 .unwrap();
693 writeln!(
694 out,
695 " {:<40} {}",
696 "Dynamic-only (after)",
697 format_size(self.dynamic_weight_b)
698 )
699 .unwrap();
700 let dyn_sign = if self.dynamic_weight_delta >= 0 {
701 "+"
702 } else {
703 "-"
704 };
705 writeln!(
706 out,
707 " {:<40} {dyn_sign}{}",
708 "Dynamic delta",
709 format_size(self.dynamic_weight_delta.unsigned_abs())
710 )
711 .unwrap();
712 }
713
714 writeln!(out).unwrap();
715
716 let limit = self.limit;
717 let show_count = |total: usize| -> usize {
718 if limit < 0 {
719 total
720 } else {
721 total.min(limit as usize)
722 }
723 };
724
725 if !self.only_in_a.is_empty() {
726 let show = show_count(self.only_in_a.len());
727 writeln!(out, "{}", c.red(&format!("Only in {}:", self.entry_a))).unwrap();
728 for pkg in &self.only_in_a[..show] {
729 writeln!(
730 out,
731 "{}",
732 c.red(&format!(" - {:<35} {}", pkg.name, format_size(pkg.size)))
733 )
734 .unwrap();
735 }
736 let remaining = self.only_in_a.len() - show;
737 if remaining > 0 {
738 writeln!(out, "{}", c.dim(&format!(" - ... and {remaining} more"))).unwrap();
739 }
740 }
741 if !self.only_in_b.is_empty() {
742 let show = show_count(self.only_in_b.len());
743 writeln!(out, "{}", c.green(&format!("Only in {}:", self.entry_b))).unwrap();
744 for pkg in &self.only_in_b[..show] {
745 writeln!(
746 out,
747 "{}",
748 c.green(&format!(" + {:<35} {}", pkg.name, format_size(pkg.size)))
749 )
750 .unwrap();
751 }
752 let remaining = self.only_in_b.len() - show;
753 if remaining > 0 {
754 writeln!(out, "{}", c.dim(&format!(" + ... and {remaining} more"))).unwrap();
755 }
756 }
757
758 if !self.dynamic_only_in_a.is_empty() {
759 let show = show_count(self.dynamic_only_in_a.len());
760 writeln!(
761 out,
762 "{}",
763 c.red(&format!("Dynamic only in {}:", self.entry_a))
764 )
765 .unwrap();
766 for pkg in &self.dynamic_only_in_a[..show] {
767 writeln!(
768 out,
769 "{}",
770 c.red(&format!(" - {:<35} {}", pkg.name, format_size(pkg.size)))
771 )
772 .unwrap();
773 }
774 let remaining = self.dynamic_only_in_a.len() - show;
775 if remaining > 0 {
776 writeln!(out, "{}", c.dim(&format!(" - ... and {remaining} more"))).unwrap();
777 }
778 }
779 if !self.dynamic_only_in_b.is_empty() {
780 let show = show_count(self.dynamic_only_in_b.len());
781 writeln!(
782 out,
783 "{}",
784 c.green(&format!("Dynamic only in {}:", self.entry_b))
785 )
786 .unwrap();
787 for pkg in &self.dynamic_only_in_b[..show] {
788 writeln!(
789 out,
790 "{}",
791 c.green(&format!(" + {:<35} {}", pkg.name, format_size(pkg.size)))
792 )
793 .unwrap();
794 }
795 let remaining = self.dynamic_only_in_b.len() - show;
796 if remaining > 0 {
797 writeln!(out, "{}", c.dim(&format!(" + ... and {remaining} more"))).unwrap();
798 }
799 }
800
801 if self.shared_count > 0 {
802 writeln!(
803 out,
804 "{}",
805 c.dim(&format!(
806 "Shared: {} package{}",
807 self.shared_count,
808 if self.shared_count == 1 { "" } else { "s" }
809 ))
810 )
811 .unwrap();
812 }
813
814 out
815 }
816}
817
818impl PackagesReport {
819 pub fn to_json(&self) -> String {
820 serde_json::to_string_pretty(self).unwrap()
821 }
822
823 #[allow(clippy::cast_sign_loss)]
824 pub fn to_terminal(&self, color: bool) -> String {
825 let c = C { color };
826 let mut out = String::new();
827
828 if self.packages.is_empty() {
829 writeln!(
830 out,
831 "No third-party packages found in the dependency graph."
832 )
833 .unwrap();
834 return out;
835 }
836
837 writeln!(
838 out,
839 "{}\n",
840 c.bold_green(&format!(
841 "{} package{}:",
842 self.package_count,
843 plural(self.package_count as u64)
844 ))
845 )
846 .unwrap();
847 for pkg in &self.packages {
848 writeln!(
849 out,
850 " {:<40} {:>8} {} file{}",
851 pkg.name,
852 format_size(pkg.total_size_bytes),
853 pkg.file_count,
854 plural(u64::from(pkg.file_count))
855 )
856 .unwrap();
857 }
858 if self.package_count > self.packages.len() {
859 let remaining = self.package_count - self.packages.len();
860 writeln!(
861 out,
862 " ... and {remaining} more package{}",
863 plural(remaining as u64)
864 )
865 .unwrap();
866 }
867
868 out
869 }
870}
871
872#[cfg(test)]
873mod tests {
874 use super::*;
875
876 #[test]
877 fn color_enabled_when_tty_and_no_overrides() {
878 assert!(should_use_color(true, false, false, false));
879 }
880
881 #[test]
882 fn color_disabled_when_not_tty() {
883 assert!(!should_use_color(false, false, false, false));
884 }
885
886 #[test]
887 fn color_disabled_by_flag() {
888 assert!(!should_use_color(true, true, false, false));
889 }
890
891 #[test]
892 fn color_disabled_by_no_color_env() {
893 assert!(!should_use_color(true, false, true, false));
894 }
895
896 #[test]
897 fn color_disabled_by_term_dumb() {
898 assert!(!should_use_color(true, false, false, true));
899 }
900
901 #[test]
902 fn package_relative_path_pnpm_store() {
903 let path = PathBuf::from(
905 "/dev/cloudflare/workers-sdk/node_modules/.pnpm/cloudflare@5.2.0/node_modules/cloudflare/index.js",
906 );
907 assert_eq!(
908 package_relative_path(&path, "cloudflare"),
909 "cloudflare/index.js"
910 );
911 }
912
913 #[test]
914 fn package_relative_path_scoped_pnpm() {
915 let path = PathBuf::from(
916 "/project/node_modules/.pnpm/@babel+parser@7.25.0/node_modules/@babel/parser/lib/index.js",
917 );
918 assert_eq!(
919 package_relative_path(&path, "@babel/parser"),
920 "@babel/parser/lib/index.js"
921 );
922 }
923
924 #[test]
925 fn package_relative_path_simple() {
926 let path = PathBuf::from("/project/node_modules/lodash/fp/map.js");
928 assert_eq!(package_relative_path(&path, "lodash"), "lodash/fp/map.js");
929 }
930
931 #[test]
932 fn trace_report_json_field_names() {
933 let report = TraceReport {
934 entry: "src/index.ts".into(),
935 static_weight_bytes: 1000,
936 static_module_count: 5,
937 dynamic_only_weight_bytes: 200,
938 dynamic_only_module_count: 1,
939 heavy_packages: vec![PackageEntry {
940 name: "zod".into(),
941 total_size_bytes: 500,
942 file_count: 3,
943 chain: vec!["src/index.ts".into(), "zod".into()],
944 }],
945 modules_by_cost: vec![ModuleEntry {
946 path: "src/utils.ts".into(),
947 exclusive_size_bytes: 100,
948 }],
949 total_modules_with_cost: 10,
950 include_dynamic: false,
951 top: 10,
952 };
953 let json: serde_json::Value = serde_json::from_str(&report.to_json()).unwrap();
954 assert!(json["entry"].is_string());
955 assert!(json["static_weight_bytes"].is_number());
956 assert!(json["heavy_packages"][0]["total_size_bytes"].is_number());
957 assert!(json["modules_by_cost"][0]["exclusive_size_bytes"].is_number());
958 assert_eq!(json["total_modules_with_cost"], 10);
959 assert!(json.get("include_dynamic").is_none());
961 }
962
963 #[test]
964 fn chain_report_json_fields() {
965 let report = ChainReport {
966 target: "zod".into(),
967 found_in_graph: true,
968 chain_count: 1,
969 hop_count: 2,
970 chains: vec![vec![
971 "src/index.ts".into(),
972 "src/lib.ts".into(),
973 "zod".into(),
974 ]],
975 };
976 let json: serde_json::Value = serde_json::from_str(&report.to_json()).unwrap();
977 assert!(json["target"].is_string());
978 assert!(json["found_in_graph"].is_boolean());
979 assert!(json["chains"][0].is_array());
980 }
981
982 #[test]
983 fn cut_report_json_fields() {
984 let report = CutReport {
985 target: "zod".into(),
986 found_in_graph: true,
987 chain_count: 2,
988 direct_import: false,
989 cut_points: vec![CutEntry {
990 module: "src/bridge.ts".into(),
991 exclusive_size_bytes: 5000,
992 chains_broken: 2,
993 }],
994 };
995 let json: serde_json::Value = serde_json::from_str(&report.to_json()).unwrap();
996 assert!(json["cut_points"][0]["exclusive_size_bytes"].is_number());
997 assert!(json["cut_points"][0]["chains_broken"].is_number());
998 }
999
1000 #[test]
1001 fn diff_report_json_skips_limit() {
1002 let report = DiffReport {
1003 entry_a: "a.ts".into(),
1004 entry_b: "b.ts".into(),
1005 weight_a: 1000,
1006 weight_b: 800,
1007 weight_delta: -200,
1008 dynamic_weight_a: 0,
1009 dynamic_weight_b: 0,
1010 dynamic_weight_delta: 0,
1011 shared_count: 1,
1012 only_in_a: vec![],
1013 only_in_b: vec![],
1014 dynamic_only_in_a: vec![],
1015 dynamic_only_in_b: vec![],
1016 limit: 10,
1017 };
1018 let json: serde_json::Value = serde_json::from_str(&report.to_json()).unwrap();
1019 assert!(json["weight_delta"].is_number());
1020 assert!(json.get("limit").is_none());
1022 }
1023
1024 #[test]
1025 fn packages_report_json_fields() {
1026 let report = PackagesReport {
1027 package_count: 2,
1028 packages: vec![PackageListEntry {
1029 name: "zod".into(),
1030 total_size_bytes: 500,
1031 file_count: 3,
1032 }],
1033 };
1034 let json: serde_json::Value = serde_json::from_str(&report.to_json()).unwrap();
1035 assert_eq!(json["package_count"], 2);
1036 assert_eq!(json["packages"][0]["name"], "zod");
1037 assert_eq!(json["packages"][0]["total_size_bytes"], 500);
1038 assert_eq!(json["packages"][0]["file_count"], 3);
1039 }
1040
1041 #[test]
1042 fn trace_report_terminal_contains_entry() {
1043 let report = TraceReport {
1044 entry: "src/index.ts".into(),
1045 static_weight_bytes: 1000,
1046 static_module_count: 5,
1047 dynamic_only_weight_bytes: 0,
1048 dynamic_only_module_count: 0,
1049 heavy_packages: vec![],
1050 modules_by_cost: vec![],
1051 total_modules_with_cost: 0,
1052 include_dynamic: false,
1053 top: 10,
1054 };
1055 let output = report.to_terminal(false);
1056 assert!(output.contains("src/index.ts"));
1057 assert!(output.contains("Static transitive weight:"));
1058 assert!(output.contains("1 KB"));
1059 }
1060
1061 #[test]
1062 fn trace_report_top_zero_hides_heavy_deps() {
1063 let report = TraceReport {
1064 entry: "src/index.ts".into(),
1065 static_weight_bytes: 1000,
1066 static_module_count: 5,
1067 dynamic_only_weight_bytes: 0,
1068 dynamic_only_module_count: 0,
1069 heavy_packages: vec![],
1070 modules_by_cost: vec![],
1071 total_modules_with_cost: 0,
1072 include_dynamic: false,
1073 top: 0,
1074 };
1075 let output = report.to_terminal(false);
1076 assert!(!output.contains("Heavy dependencies"));
1077 assert!(!output.contains("all reachable modules are first-party"));
1078 }
1079
1080 #[test]
1081 fn trace_report_top_zero_json_skips_field() {
1082 let report = TraceReport {
1083 entry: "src/index.ts".into(),
1084 static_weight_bytes: 1000,
1085 static_module_count: 5,
1086 dynamic_only_weight_bytes: 0,
1087 dynamic_only_module_count: 0,
1088 heavy_packages: vec![],
1089 modules_by_cost: vec![],
1090 total_modules_with_cost: 0,
1091 include_dynamic: false,
1092 top: 0,
1093 };
1094 let json: serde_json::Value = serde_json::from_str(&report.to_json()).unwrap();
1095 assert!(json.get("top").is_none());
1096 }
1097
1098 #[test]
1099 fn diff_report_from_diff_roundtrip() {
1100 use crate::query::{self, TraceSnapshot};
1101 let a = TraceSnapshot {
1102 entry: "a.ts".into(),
1103 static_weight: 1000,
1104 packages: [("zod".into(), 500)].into_iter().collect(),
1105 dynamic_weight: 0,
1106 dynamic_packages: HashMap::new(),
1107 };
1108 let b = TraceSnapshot {
1109 entry: "b.ts".into(),
1110 static_weight: 800,
1111 packages: [("chalk".into(), 300)].into_iter().collect(),
1112 dynamic_weight: 0,
1113 dynamic_packages: HashMap::new(),
1114 };
1115 let diff = query::diff_snapshots(&a, &b);
1116 let report = DiffReport::from_diff(&diff, "a.ts", "b.ts", 10);
1117 assert_eq!(report.weight_a, 1000);
1118 assert_eq!(report.weight_b, 800);
1119 assert_eq!(report.weight_delta, -200);
1120 assert_eq!(report.only_in_a.len(), 1);
1121 assert_eq!(report.only_in_a[0].name, "zod");
1122 }
1123}