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