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::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
13/// Default number of heavy dependencies to display.
14pub const DEFAULT_TOP: i32 = 10;
15/// Default number of modules by exclusive weight to display.
16pub const DEFAULT_TOP_MODULES: i32 = 20;
17
18/// Determine whether color output should be used for a given stream.
19///
20/// Color is disabled when any of these hold:
21/// - `no_color_flag` is true (`--no-color`)
22/// - `no_color_env` is true (`NO_COLOR` set, per <https://no-color.org>)
23/// - `term_dumb` is true (`TERM=dumb`)
24/// - the stream is not a TTY
25#[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/// Print the standard graph-load status line plus any warnings.
124///
125/// Used by the CLI (trace, packages, diff) and the REPL startup to avoid
126/// duplicating the same formatting logic.
127#[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
191/// Path relative to the package directory (e.g. `dateutil/__init__.py`).
192/// Handles scoped packages (`@scope/name` spans two path components).
193/// Falls back to the file name if the package name isn't found in path components.
194fn package_relative_path(path: &Path, package_name: &str) -> String {
195    let components: Vec<_> = path.components().collect();
196    // Scan backwards to find the last match — avoids false matches from
197    // workspace dirs that share a package name (e.g. pnpm store paths
198    // like .pnpm/cloudflare@5.2.0/node_modules/cloudflare/index.js where
199    // an ancestor dir is also named "cloudflare").
200    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
226/// Build display names for a chain, expanding duplicate package nodes
227/// to package-relative file paths for disambiguation.
228pub(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// ---------------------------------------------------------------------------
257// Structured report types
258// ---------------------------------------------------------------------------
259
260/// Display-ready trace result. Produced by `Session::trace_report()`.
261#[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    /// Total modules with non-zero exclusive weight (before truncation).
271    pub total_modules_with_cost: usize,
272    /// Whether dynamic imports were included in the trace.
273    #[serde(skip)]
274    pub include_dynamic: bool,
275    /// The `--top` value (0 = hide heavy deps section entirely).
276    #[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/// Display-ready chain result. Produced by `Session::chain_report()`.
295#[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/// Display-ready cut result. Produced by `Session::cut_report()`.
305#[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/// Display-ready diff result. Produced by `Session::diff_report()` or
322/// `DiffReport::from_diff()`.
323#[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    /// Max packages to show per section (-1 for all).
339    #[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/// Display-ready packages list. Produced by `Session::packages_report()`.
350#[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
363// ---------------------------------------------------------------------------
364// Report rendering
365// ---------------------------------------------------------------------------
366
367impl 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        // pnpm store path where workspace dir matches package name
904        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        // Non-pnpm: straightforward node_modules/pkg/file
927        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        // include_dynamic should not appear in JSON (serde skip)
960        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        // limit should not appear in JSON (serde skip)
1021        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}