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 total_size_bytes: 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// Emit helper — centralizes --json dispatch
365// ---------------------------------------------------------------------------
366
367/// Emit a report to stdout, choosing JSON or terminal format.
368///
369/// Centralizes the `--json` / terminal dispatch so every CLI output path
370/// goes through a single function. New output paths that forget the json
371/// flag will be caught in code review: the pattern is always
372/// `report::emit(json, ...)` rather than ad-hoc if/else.
373pub 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
381// ---------------------------------------------------------------------------
382// Report rendering
383// ---------------------------------------------------------------------------
384
385impl 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        // pnpm store path where workspace dir matches package name
938        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        // Non-pnpm: straightforward node_modules/pkg/file
961        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        // include_dynamic should not appear in JSON (serde skip)
994        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        // limit should not appear in JSON (serde skip)
1055        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}