Skip to main content

chainsaw/
report.rs

1//! Human-readable output formatting for trace results, diffs, and package lists.
2
3use 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/// Default number of heavy dependencies to display.
13pub const DEFAULT_TOP: i32 = 10;
14/// Default number of modules by exclusive weight to display.
15pub const DEFAULT_TOP_MODULES: i32 = 20;
16
17/// Determine whether color output should be used for a given stream.
18///
19/// Color is disabled when any of these hold:
20/// - `no_color_flag` is true (`--no-color`)
21/// - `no_color_env` is true (`NO_COLOR` set, per <https://no-color.org>)
22/// - `term_dumb` is true (`TERM=dumb`)
23/// - the stream is not a TTY
24#[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/// Print the standard graph-load status line plus any warnings.
134///
135/// Used by the CLI (trace, packages, diff) and the REPL startup to avoid
136/// duplicating the same formatting logic.
137#[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
201/// Path relative to the package directory (e.g. `dateutil/__init__.py`).
202/// Handles scoped packages (`@scope/name` spans two path components).
203/// Falls back to the file name if the package name isn't found in path components.
204fn package_relative_path(path: &Path, package_name: &str) -> String {
205    let components: Vec<_> = path.components().collect();
206    // Scan backwards to find the last match — avoids false matches from
207    // workspace dirs that share a package name (e.g. pnpm store paths
208    // like .pnpm/cloudflare@5.2.0/node_modules/cloudflare/index.js where
209    // an ancestor dir is also named "cloudflare").
210    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
245/// Build display names for a chain, expanding duplicate package nodes
246/// to package-relative file paths for disambiguation.
247fn 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// JSON output types
744
745#[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        // pnpm store path where workspace dir matches package name
879        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        // Non-pnpm: straightforward node_modules/pkg/file
902        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}