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