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 total_size_bytes: 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
363pub fn emit(json: bool, json_fn: impl FnOnce() -> String, terminal_fn: impl FnOnce() -> String) {
374 if json {
375 println!("{}", json_fn());
376 } else {
377 print!("{}", terminal_fn());
378 }
379}
380
381impl TraceReport {
386 pub fn to_json(&self) -> String {
387 serde_json::to_string_pretty(self).unwrap()
388 }
389
390 pub fn to_terminal(&self, color: bool) -> String {
391 let c = C { color };
392 let mut out = String::new();
393 writeln!(out, "{}", self.entry).unwrap();
394
395 let suffix = if self.include_dynamic {
396 ", static + dynamic"
397 } else {
398 ""
399 };
400 let weight = format_size(self.static_weight_bytes);
401 let modules = format!(
402 "{} module{}{}",
403 self.static_module_count,
404 plural(self.static_module_count as u64),
405 suffix,
406 );
407 let label = if self.include_dynamic {
408 "Total transitive weight:"
409 } else {
410 "Static transitive weight:"
411 };
412 writeln!(out, "{} {weight} ({modules})", c.bold_green(label)).unwrap();
413
414 if !self.include_dynamic && self.dynamic_only_module_count > 0 {
415 writeln!(
416 out,
417 "{} {} ({} module{}, not loaded at startup)",
418 c.bold_green("Dynamic-only weight:"),
419 format_size(self.dynamic_only_weight_bytes),
420 self.dynamic_only_module_count,
421 plural(self.dynamic_only_module_count as u64)
422 )
423 .unwrap();
424 }
425
426 if self.top != 0 {
427 writeln!(out).unwrap();
428 let deps_label = if self.include_dynamic {
429 "Heavy dependencies (static + dynamic):"
430 } else {
431 "Heavy dependencies (static):"
432 };
433 writeln!(out, "{}", c.bold_green(deps_label)).unwrap();
434 if self.heavy_packages.is_empty() {
435 writeln!(
436 out,
437 " (none \u{2014} all reachable modules are first-party)"
438 )
439 .unwrap();
440 } else {
441 for pkg in &self.heavy_packages {
442 writeln!(
443 out,
444 " {:<35} {} {} file{}",
445 pkg.name,
446 format_size(pkg.total_size_bytes),
447 pkg.file_count,
448 plural(u64::from(pkg.file_count))
449 )
450 .unwrap();
451 if pkg.chain.len() > 1 {
452 writeln!(out, " -> {}", pkg.chain.join(" -> ")).unwrap();
453 }
454 }
455 }
456 writeln!(out).unwrap();
457 }
458
459 if !self.modules_by_cost.is_empty() {
460 writeln!(
461 out,
462 "{}",
463 c.bold_green("Modules (sorted by exclusive weight):")
464 )
465 .unwrap();
466 for mc in &self.modules_by_cost {
467 writeln!(
468 out,
469 " {:<55} {}",
470 mc.path,
471 format_size(mc.exclusive_size_bytes)
472 )
473 .unwrap();
474 }
475 if self.total_modules_with_cost > self.modules_by_cost.len() {
476 let remaining = self.total_modules_with_cost - self.modules_by_cost.len();
477 writeln!(
478 out,
479 " ... and {remaining} more module{}",
480 plural(remaining as u64)
481 )
482 .unwrap();
483 }
484 }
485
486 out
487 }
488}
489
490impl ChainReport {
491 pub fn to_json(&self) -> String {
492 serde_json::to_string_pretty(self).unwrap()
493 }
494
495 pub fn to_terminal(&self, color: bool) -> String {
496 let c = C { color };
497 let mut out = String::new();
498
499 if self.chains.is_empty() {
500 if self.found_in_graph {
501 writeln!(
502 out,
503 "\"{}\" exists in the graph but is not reachable from this entry point.",
504 self.target
505 )
506 .unwrap();
507 } else {
508 writeln!(
509 out,
510 "\"{}\" is not in the dependency graph. Check the spelling or verify it's installed.",
511 self.target
512 )
513 .unwrap();
514 }
515 return out;
516 }
517
518 writeln!(
519 out,
520 "{}\n",
521 c.bold_green(&format!(
522 "{} chain{} to \"{}\" ({} hop{}):",
523 self.chain_count,
524 if self.chain_count == 1 { "" } else { "s" },
525 self.target,
526 self.hop_count,
527 if self.hop_count == 1 { "" } else { "s" },
528 )),
529 )
530 .unwrap();
531 for (i, chain) in self.chains.iter().enumerate() {
532 writeln!(out, " {}. {}", i + 1, chain.join(" -> ")).unwrap();
533 }
534
535 out
536 }
537}
538
539impl CutReport {
540 pub fn to_json(&self) -> String {
541 serde_json::to_string_pretty(self).unwrap()
542 }
543
544 pub fn to_terminal(&self, color: bool) -> String {
545 let c = C { color };
546 let mut out = String::new();
547
548 if self.chain_count == 0 {
549 if self.found_in_graph {
550 writeln!(
551 out,
552 "\"{}\" exists in the graph but is not reachable from this entry point.",
553 self.target
554 )
555 .unwrap();
556 } else {
557 writeln!(
558 out,
559 "\"{}\" is not in the dependency graph. Check the spelling or verify it's installed.",
560 self.target
561 )
562 .unwrap();
563 }
564 return out;
565 }
566
567 if self.cut_points.is_empty() {
568 if self.direct_import {
569 writeln!(
570 out,
571 "Entry file directly imports \"{}\" \u{2014} remove the import to sever the dependency.",
572 self.target
573 )
574 .unwrap();
575 } else {
576 writeln!(
577 out,
578 "No single cut point can sever all {} chain{} to \"{}\".",
579 self.chain_count,
580 if self.chain_count == 1 { "" } else { "s" },
581 self.target,
582 )
583 .unwrap();
584 writeln!(
585 out,
586 "Each chain takes a different path \u{2014} multiple fixes needed."
587 )
588 .unwrap();
589 }
590 return out;
591 }
592
593 writeln!(
594 out,
595 "{}\n",
596 c.bold_green(&format!(
597 "{} cut point{} to sever all {} chain{} to \"{}\":",
598 self.cut_points.len(),
599 if self.cut_points.len() == 1 { "" } else { "s" },
600 self.chain_count,
601 if self.chain_count == 1 { "" } else { "s" },
602 self.target,
603 )),
604 )
605 .unwrap();
606 for cut in &self.cut_points {
607 if self.chain_count == 1 {
608 writeln!(
609 out,
610 " {:<45} {:>8}",
611 cut.module,
612 format_size(cut.exclusive_size_bytes),
613 )
614 .unwrap();
615 } else {
616 writeln!(
617 out,
618 " {:<45} {:>8} (breaks {}/{} chains)",
619 cut.module,
620 format_size(cut.exclusive_size_bytes),
621 cut.chains_broken,
622 self.chain_count
623 )
624 .unwrap();
625 }
626 }
627
628 out
629 }
630}
631
632impl DiffReport {
633 pub fn to_json(&self) -> String {
634 serde_json::to_string_pretty(self).unwrap()
635 }
636
637 pub fn from_diff(diff: &DiffResult, entry_a: &str, entry_b: &str, limit: i32) -> Self {
638 Self {
639 entry_a: entry_a.to_string(),
640 entry_b: entry_b.to_string(),
641 weight_a: diff.entry_a_weight,
642 weight_b: diff.entry_b_weight,
643 weight_delta: diff.weight_delta,
644 dynamic_weight_a: diff.dynamic_a_weight,
645 dynamic_weight_b: diff.dynamic_b_weight,
646 dynamic_weight_delta: diff.dynamic_weight_delta,
647 shared_count: diff.shared_count,
648 only_in_a: diff
649 .only_in_a
650 .iter()
651 .map(|p| DiffPackageEntry {
652 name: p.name.clone(),
653 total_size_bytes: p.total_size_bytes,
654 })
655 .collect(),
656 only_in_b: diff
657 .only_in_b
658 .iter()
659 .map(|p| DiffPackageEntry {
660 name: p.name.clone(),
661 total_size_bytes: p.total_size_bytes,
662 })
663 .collect(),
664 dynamic_only_in_a: diff
665 .dynamic_only_in_a
666 .iter()
667 .map(|p| DiffPackageEntry {
668 name: p.name.clone(),
669 total_size_bytes: p.total_size_bytes,
670 })
671 .collect(),
672 dynamic_only_in_b: diff
673 .dynamic_only_in_b
674 .iter()
675 .map(|p| DiffPackageEntry {
676 name: p.name.clone(),
677 total_size_bytes: p.total_size_bytes,
678 })
679 .collect(),
680 limit,
681 }
682 }
683
684 #[allow(clippy::cast_sign_loss, clippy::too_many_lines)]
685 pub fn to_terminal(&self, color: bool) -> String {
686 let c = C { color };
687 let mut out = String::new();
688 writeln!(out, "Diff: {} vs {}", self.entry_a, self.entry_b).unwrap();
689 writeln!(out).unwrap();
690 writeln!(out, " {:<40} {}", self.entry_a, format_size(self.weight_a)).unwrap();
691 writeln!(out, " {:<40} {}", self.entry_b, format_size(self.weight_b)).unwrap();
692 let sign = if self.weight_delta >= 0 { "+" } else { "-" };
693 writeln!(
694 out,
695 " {:<40} {sign}{}",
696 "Delta",
697 format_size(self.weight_delta.unsigned_abs())
698 )
699 .unwrap();
700
701 let has_dynamic = self.dynamic_weight_a > 0 || self.dynamic_weight_b > 0;
702 if has_dynamic {
703 writeln!(out).unwrap();
704 writeln!(
705 out,
706 " {:<40} {}",
707 "Dynamic-only (before)",
708 format_size(self.dynamic_weight_a)
709 )
710 .unwrap();
711 writeln!(
712 out,
713 " {:<40} {}",
714 "Dynamic-only (after)",
715 format_size(self.dynamic_weight_b)
716 )
717 .unwrap();
718 let dyn_sign = if self.dynamic_weight_delta >= 0 {
719 "+"
720 } else {
721 "-"
722 };
723 writeln!(
724 out,
725 " {:<40} {dyn_sign}{}",
726 "Dynamic delta",
727 format_size(self.dynamic_weight_delta.unsigned_abs())
728 )
729 .unwrap();
730 }
731
732 writeln!(out).unwrap();
733
734 let limit = self.limit;
735 let show_count = |total: usize| -> usize {
736 if limit < 0 {
737 total
738 } else {
739 total.min(limit as usize)
740 }
741 };
742
743 if !self.only_in_a.is_empty() {
744 let show = show_count(self.only_in_a.len());
745 writeln!(out, "{}", c.red(&format!("Only in {}:", self.entry_a))).unwrap();
746 for pkg in &self.only_in_a[..show] {
747 writeln!(
748 out,
749 "{}",
750 c.red(&format!(
751 " - {:<35} {}",
752 pkg.name,
753 format_size(pkg.total_size_bytes)
754 ))
755 )
756 .unwrap();
757 }
758 let remaining = self.only_in_a.len() - show;
759 if remaining > 0 {
760 writeln!(out, "{}", c.dim(&format!(" - ... and {remaining} more"))).unwrap();
761 }
762 }
763 if !self.only_in_b.is_empty() {
764 let show = show_count(self.only_in_b.len());
765 writeln!(out, "{}", c.green(&format!("Only in {}:", self.entry_b))).unwrap();
766 for pkg in &self.only_in_b[..show] {
767 writeln!(
768 out,
769 "{}",
770 c.green(&format!(
771 " + {:<35} {}",
772 pkg.name,
773 format_size(pkg.total_size_bytes)
774 ))
775 )
776 .unwrap();
777 }
778 let remaining = self.only_in_b.len() - show;
779 if remaining > 0 {
780 writeln!(out, "{}", c.dim(&format!(" + ... and {remaining} more"))).unwrap();
781 }
782 }
783
784 if !self.dynamic_only_in_a.is_empty() {
785 let show = show_count(self.dynamic_only_in_a.len());
786 writeln!(
787 out,
788 "{}",
789 c.red(&format!("Dynamic only in {}:", self.entry_a))
790 )
791 .unwrap();
792 for pkg in &self.dynamic_only_in_a[..show] {
793 writeln!(
794 out,
795 "{}",
796 c.red(&format!(
797 " - {:<35} {}",
798 pkg.name,
799 format_size(pkg.total_size_bytes)
800 ))
801 )
802 .unwrap();
803 }
804 let remaining = self.dynamic_only_in_a.len() - show;
805 if remaining > 0 {
806 writeln!(out, "{}", c.dim(&format!(" - ... and {remaining} more"))).unwrap();
807 }
808 }
809 if !self.dynamic_only_in_b.is_empty() {
810 let show = show_count(self.dynamic_only_in_b.len());
811 writeln!(
812 out,
813 "{}",
814 c.green(&format!("Dynamic only in {}:", self.entry_b))
815 )
816 .unwrap();
817 for pkg in &self.dynamic_only_in_b[..show] {
818 writeln!(
819 out,
820 "{}",
821 c.green(&format!(
822 " + {:<35} {}",
823 pkg.name,
824 format_size(pkg.total_size_bytes)
825 ))
826 )
827 .unwrap();
828 }
829 let remaining = self.dynamic_only_in_b.len() - show;
830 if remaining > 0 {
831 writeln!(out, "{}", c.dim(&format!(" + ... and {remaining} more"))).unwrap();
832 }
833 }
834
835 if self.shared_count > 0 {
836 writeln!(
837 out,
838 "{}",
839 c.dim(&format!(
840 "Shared: {} package{}",
841 self.shared_count,
842 if self.shared_count == 1 { "" } else { "s" }
843 ))
844 )
845 .unwrap();
846 }
847
848 out
849 }
850}
851
852impl PackagesReport {
853 pub fn to_json(&self) -> String {
854 serde_json::to_string_pretty(self).unwrap()
855 }
856
857 #[allow(clippy::cast_sign_loss)]
858 pub fn to_terminal(&self, color: bool) -> String {
859 let c = C { color };
860 let mut out = String::new();
861
862 if self.packages.is_empty() {
863 writeln!(
864 out,
865 "No third-party packages found in the dependency graph."
866 )
867 .unwrap();
868 return out;
869 }
870
871 writeln!(
872 out,
873 "{}\n",
874 c.bold_green(&format!(
875 "{} package{}:",
876 self.package_count,
877 plural(self.package_count as u64)
878 ))
879 )
880 .unwrap();
881 for pkg in &self.packages {
882 writeln!(
883 out,
884 " {:<40} {:>8} {} file{}",
885 pkg.name,
886 format_size(pkg.total_size_bytes),
887 pkg.file_count,
888 plural(u64::from(pkg.file_count))
889 )
890 .unwrap();
891 }
892 if self.package_count > self.packages.len() {
893 let remaining = self.package_count - self.packages.len();
894 writeln!(
895 out,
896 " ... and {remaining} more package{}",
897 plural(remaining as u64)
898 )
899 .unwrap();
900 }
901
902 out
903 }
904}
905
906#[cfg(test)]
907mod tests {
908 use super::*;
909
910 #[test]
911 fn color_enabled_when_tty_and_no_overrides() {
912 assert!(should_use_color(true, false, false, false));
913 }
914
915 #[test]
916 fn color_disabled_when_not_tty() {
917 assert!(!should_use_color(false, false, false, false));
918 }
919
920 #[test]
921 fn color_disabled_by_flag() {
922 assert!(!should_use_color(true, true, false, false));
923 }
924
925 #[test]
926 fn color_disabled_by_no_color_env() {
927 assert!(!should_use_color(true, false, true, false));
928 }
929
930 #[test]
931 fn color_disabled_by_term_dumb() {
932 assert!(!should_use_color(true, false, false, true));
933 }
934
935 #[test]
936 fn package_relative_path_pnpm_store() {
937 let path = PathBuf::from(
939 "/dev/cloudflare/workers-sdk/node_modules/.pnpm/cloudflare@5.2.0/node_modules/cloudflare/index.js",
940 );
941 assert_eq!(
942 package_relative_path(&path, "cloudflare"),
943 "cloudflare/index.js"
944 );
945 }
946
947 #[test]
948 fn package_relative_path_scoped_pnpm() {
949 let path = PathBuf::from(
950 "/project/node_modules/.pnpm/@babel+parser@7.25.0/node_modules/@babel/parser/lib/index.js",
951 );
952 assert_eq!(
953 package_relative_path(&path, "@babel/parser"),
954 "@babel/parser/lib/index.js"
955 );
956 }
957
958 #[test]
959 fn package_relative_path_simple() {
960 let path = PathBuf::from("/project/node_modules/lodash/fp/map.js");
962 assert_eq!(package_relative_path(&path, "lodash"), "lodash/fp/map.js");
963 }
964
965 #[test]
966 fn trace_report_json_field_names() {
967 let report = TraceReport {
968 entry: "src/index.ts".into(),
969 static_weight_bytes: 1000,
970 static_module_count: 5,
971 dynamic_only_weight_bytes: 200,
972 dynamic_only_module_count: 1,
973 heavy_packages: vec![PackageEntry {
974 name: "zod".into(),
975 total_size_bytes: 500,
976 file_count: 3,
977 chain: vec!["src/index.ts".into(), "zod".into()],
978 }],
979 modules_by_cost: vec![ModuleEntry {
980 path: "src/utils.ts".into(),
981 exclusive_size_bytes: 100,
982 }],
983 total_modules_with_cost: 10,
984 include_dynamic: false,
985 top: 10,
986 };
987 let json: serde_json::Value = serde_json::from_str(&report.to_json()).unwrap();
988 assert!(json["entry"].is_string());
989 assert!(json["static_weight_bytes"].is_number());
990 assert!(json["heavy_packages"][0]["total_size_bytes"].is_number());
991 assert!(json["modules_by_cost"][0]["exclusive_size_bytes"].is_number());
992 assert_eq!(json["total_modules_with_cost"], 10);
993 assert!(json.get("include_dynamic").is_none());
995 }
996
997 #[test]
998 fn chain_report_json_fields() {
999 let report = ChainReport {
1000 target: "zod".into(),
1001 found_in_graph: true,
1002 chain_count: 1,
1003 hop_count: 2,
1004 chains: vec![vec![
1005 "src/index.ts".into(),
1006 "src/lib.ts".into(),
1007 "zod".into(),
1008 ]],
1009 };
1010 let json: serde_json::Value = serde_json::from_str(&report.to_json()).unwrap();
1011 assert!(json["target"].is_string());
1012 assert!(json["found_in_graph"].is_boolean());
1013 assert!(json["chains"][0].is_array());
1014 }
1015
1016 #[test]
1017 fn cut_report_json_fields() {
1018 let report = CutReport {
1019 target: "zod".into(),
1020 found_in_graph: true,
1021 chain_count: 2,
1022 direct_import: false,
1023 cut_points: vec![CutEntry {
1024 module: "src/bridge.ts".into(),
1025 exclusive_size_bytes: 5000,
1026 chains_broken: 2,
1027 }],
1028 };
1029 let json: serde_json::Value = serde_json::from_str(&report.to_json()).unwrap();
1030 assert!(json["cut_points"][0]["exclusive_size_bytes"].is_number());
1031 assert!(json["cut_points"][0]["chains_broken"].is_number());
1032 }
1033
1034 #[test]
1035 fn diff_report_json_skips_limit() {
1036 let report = DiffReport {
1037 entry_a: "a.ts".into(),
1038 entry_b: "b.ts".into(),
1039 weight_a: 1000,
1040 weight_b: 800,
1041 weight_delta: -200,
1042 dynamic_weight_a: 0,
1043 dynamic_weight_b: 0,
1044 dynamic_weight_delta: 0,
1045 shared_count: 1,
1046 only_in_a: vec![],
1047 only_in_b: vec![],
1048 dynamic_only_in_a: vec![],
1049 dynamic_only_in_b: vec![],
1050 limit: 10,
1051 };
1052 let json: serde_json::Value = serde_json::from_str(&report.to_json()).unwrap();
1053 assert!(json["weight_delta"].is_number());
1054 assert!(json.get("limit").is_none());
1056 }
1057
1058 #[test]
1059 fn diff_report_json_package_fields() {
1060 let report = DiffReport {
1061 entry_a: "a.ts".into(),
1062 entry_b: "b.ts".into(),
1063 weight_a: 1000,
1064 weight_b: 800,
1065 weight_delta: -200,
1066 dynamic_weight_a: 0,
1067 dynamic_weight_b: 0,
1068 dynamic_weight_delta: 0,
1069 shared_count: 0,
1070 only_in_a: vec![DiffPackageEntry {
1071 name: "zod".into(),
1072 total_size_bytes: 500,
1073 }],
1074 only_in_b: vec![],
1075 dynamic_only_in_a: vec![],
1076 dynamic_only_in_b: vec![],
1077 limit: 10,
1078 };
1079 let json: serde_json::Value = serde_json::from_str(&report.to_json()).unwrap();
1080 assert_eq!(json["only_in_a"][0]["name"], "zod");
1081 assert_eq!(json["only_in_a"][0]["total_size_bytes"], 500);
1082 assert!(json["only_in_a"][0].get("size").is_none());
1083 }
1084
1085 #[test]
1086 fn packages_report_json_fields() {
1087 let report = PackagesReport {
1088 package_count: 2,
1089 packages: vec![PackageListEntry {
1090 name: "zod".into(),
1091 total_size_bytes: 500,
1092 file_count: 3,
1093 }],
1094 };
1095 let json: serde_json::Value = serde_json::from_str(&report.to_json()).unwrap();
1096 assert_eq!(json["package_count"], 2);
1097 assert_eq!(json["packages"][0]["name"], "zod");
1098 assert_eq!(json["packages"][0]["total_size_bytes"], 500);
1099 assert_eq!(json["packages"][0]["file_count"], 3);
1100 }
1101
1102 #[test]
1103 fn trace_report_terminal_contains_entry() {
1104 let report = TraceReport {
1105 entry: "src/index.ts".into(),
1106 static_weight_bytes: 1000,
1107 static_module_count: 5,
1108 dynamic_only_weight_bytes: 0,
1109 dynamic_only_module_count: 0,
1110 heavy_packages: vec![],
1111 modules_by_cost: vec![],
1112 total_modules_with_cost: 0,
1113 include_dynamic: false,
1114 top: 10,
1115 };
1116 let output = report.to_terminal(false);
1117 assert!(output.contains("src/index.ts"));
1118 assert!(output.contains("Static transitive weight:"));
1119 assert!(output.contains("1 KB"));
1120 }
1121
1122 #[test]
1123 fn trace_report_top_zero_hides_heavy_deps() {
1124 let report = TraceReport {
1125 entry: "src/index.ts".into(),
1126 static_weight_bytes: 1000,
1127 static_module_count: 5,
1128 dynamic_only_weight_bytes: 0,
1129 dynamic_only_module_count: 0,
1130 heavy_packages: vec![],
1131 modules_by_cost: vec![],
1132 total_modules_with_cost: 0,
1133 include_dynamic: false,
1134 top: 0,
1135 };
1136 let output = report.to_terminal(false);
1137 assert!(!output.contains("Heavy dependencies"));
1138 assert!(!output.contains("all reachable modules are first-party"));
1139 }
1140
1141 #[test]
1142 fn trace_report_top_zero_json_skips_field() {
1143 let report = TraceReport {
1144 entry: "src/index.ts".into(),
1145 static_weight_bytes: 1000,
1146 static_module_count: 5,
1147 dynamic_only_weight_bytes: 0,
1148 dynamic_only_module_count: 0,
1149 heavy_packages: vec![],
1150 modules_by_cost: vec![],
1151 total_modules_with_cost: 0,
1152 include_dynamic: false,
1153 top: 0,
1154 };
1155 let json: serde_json::Value = serde_json::from_str(&report.to_json()).unwrap();
1156 assert!(json.get("top").is_none());
1157 }
1158
1159 #[test]
1160 fn diff_report_from_diff_roundtrip() {
1161 use crate::query::{self, TraceSnapshot};
1162 let a = TraceSnapshot {
1163 entry: "a.ts".into(),
1164 static_weight: 1000,
1165 packages: [("zod".into(), 500)].into_iter().collect(),
1166 dynamic_weight: 0,
1167 dynamic_packages: HashMap::new(),
1168 };
1169 let b = TraceSnapshot {
1170 entry: "b.ts".into(),
1171 static_weight: 800,
1172 packages: [("chalk".into(), 300)].into_iter().collect(),
1173 dynamic_weight: 0,
1174 dynamic_packages: HashMap::new(),
1175 };
1176 let diff = query::diff_snapshots(&a, &b);
1177 let report = DiffReport::from_diff(&diff, "a.ts", "b.ts", 10);
1178 assert_eq!(report.weight_a, 1000);
1179 assert_eq!(report.weight_b, 800);
1180 assert_eq!(report.weight_delta, -200);
1181 assert_eq!(report.only_in_a.len(), 1);
1182 assert_eq!(report.only_in_a[0].name, "zod");
1183 }
1184}