Skip to main content

chainsaw/
query.rs

1//! Graph queries: trace weight, import chains, cut points, and diffs.
2
3use std::collections::{HashMap, HashSet, VecDeque};
4
5use serde::{Deserialize, Serialize};
6
7use crate::graph::{EdgeKind, ModuleGraph, ModuleId};
8
9/// Results of tracing transitive import weight from an entry module.
10#[derive(Debug)]
11#[non_exhaustive]
12pub struct TraceResult {
13    /// Total file size reachable via static imports
14    pub static_weight: u64,
15    /// Number of modules reachable via static imports
16    pub static_module_count: usize,
17    /// Total file size reachable only via dynamic imports (not already counted in static)
18    pub dynamic_only_weight: u64,
19    /// Number of modules reachable only via dynamic imports
20    pub dynamic_only_module_count: usize,
21    /// Heavy packages found via static imports, sorted by total reachable size descending
22    pub heavy_packages: Vec<HeavyPackage>,
23    /// All reachable modules with their exclusive weight, sorted descending
24    pub modules_by_cost: Vec<ModuleCost>,
25    /// All statically reachable packages with their total size
26    pub all_packages: HashMap<String, u64>,
27    /// Packages reachable only via dynamic imports (not in static set)
28    pub dynamic_packages: HashMap<String, u64>,
29}
30
31/// A third-party package with its reachable size and shortest import chain.
32#[derive(Debug)]
33#[non_exhaustive]
34pub struct HeavyPackage {
35    pub name: String,
36    pub total_size: u64,
37    pub file_count: u32,
38    /// Shortest chain from entry point to the first module in this package
39    pub chain: Vec<ModuleId>,
40}
41
42/// A module with its exclusive weight (bytes only it contributes to the graph).
43#[derive(Debug, Clone, Copy)]
44#[non_exhaustive]
45pub struct ModuleCost {
46    pub module_id: ModuleId,
47    pub exclusive_size: u64,
48}
49
50/// Options controlling which edges to follow and how many results to return.
51#[derive(Debug)]
52pub struct TraceOptions {
53    pub include_dynamic: bool,
54    pub top_n: i32,
55    pub ignore: Vec<String>,
56}
57
58impl Default for TraceOptions {
59    fn default() -> Self {
60        Self {
61            include_dynamic: false,
62            top_n: 10,
63            ignore: Vec::new(),
64        }
65    }
66}
67
68/// Target for `--chain`/`--cut` queries: a package name or a specific module.
69#[derive(Debug, Clone, PartialEq)]
70#[non_exhaustive]
71pub enum ChainTarget {
72    /// Match any module belonging to this third-party package.
73    Package(String),
74    /// Match a specific module by its graph id.
75    Module(ModuleId),
76}
77
78impl ChainTarget {
79    fn matches(&self, graph: &ModuleGraph, mid: ModuleId) -> bool {
80        match self {
81            Self::Package(name) => graph.module(mid).package.as_deref() == Some(name),
82            Self::Module(target) => mid == *target,
83        }
84    }
85}
86
87/// Whether to follow an edge based on its kind and the `include_dynamic` flag.
88const fn should_follow(kind: EdgeKind, include_dynamic: bool) -> bool {
89    match kind {
90        EdgeKind::Static => true,
91        EdgeKind::Dynamic if include_dynamic => true,
92        _ => false,
93    }
94}
95
96/// Iterative DFS to compute reverse postorder and predecessor lists in one pass.
97fn reverse_postorder_with_preds(
98    graph: &ModuleGraph,
99    entry: ModuleId,
100    include_dynamic: bool,
101) -> (Vec<ModuleId>, Vec<Vec<u32>>) {
102    let n = graph.modules.len();
103    let mut visited = vec![false; n];
104    let mut postorder = Vec::new();
105    let mut preds: Vec<Vec<u32>> = vec![Vec::new(); n];
106    let mut stack: Vec<(ModuleId, bool)> = vec![(entry, false)];
107
108    while let Some((mid, post_visit)) = stack.pop() {
109        let idx = mid.0 as usize;
110        if post_visit {
111            postorder.push(mid);
112            continue;
113        }
114        if visited[idx] {
115            continue;
116        }
117        visited[idx] = true;
118        stack.push((mid, true));
119        for &edge_id in graph.outgoing_edges(mid) {
120            let edge = graph.edge(edge_id);
121            if should_follow(edge.kind, include_dynamic) {
122                let to_idx = edge.to.0 as usize;
123                if visited[to_idx] {
124                    // Back/cross edge to already-visited (reachable) node
125                    preds[to_idx].push(mid.0);
126                } else {
127                    // Tree edge — record predecessor and visit
128                    preds[to_idx].push(mid.0);
129                    stack.push((edge.to, false));
130                }
131            }
132        }
133    }
134
135    postorder.reverse();
136    (postorder, preds)
137}
138
139/// Walk up dominator tree to find common dominator of a and b.
140fn intersect_idom(idom: &[u32], rpo_num: &[u32], mut a: u32, mut b: u32) -> u32 {
141    while a != b {
142        while rpo_num[a as usize] > rpo_num[b as usize] {
143            a = idom[a as usize];
144        }
145        while rpo_num[b as usize] > rpo_num[a as usize] {
146            b = idom[b as usize];
147        }
148    }
149    a
150}
151
152/// Compute exclusive weight for every reachable module using a dominator tree.
153///
154/// Exclusive weight of module M = total size of all modules in M's dominator
155/// subtree (modules that become unreachable if M is removed from the graph).
156/// Uses the Cooper-Harvey-Kennedy iterative dominator algorithm: O(N).
157#[allow(clippy::cast_possible_truncation)]
158fn compute_exclusive_weights(
159    graph: &ModuleGraph,
160    entry: ModuleId,
161    include_dynamic: bool,
162) -> Vec<u64> {
163    let n = graph.modules.len();
164
165    // Step 1+3: DFS for reverse postorder and predecessor lists in one pass
166    let (rpo, preds) = reverse_postorder_with_preds(graph, entry, include_dynamic);
167    if rpo.is_empty() {
168        return vec![0; n];
169    }
170
171    // Step 2: RPO numbering (lower = earlier)
172    let mut rpo_num = vec![u32::MAX; n];
173    for (i, &mid) in rpo.iter().enumerate() {
174        rpo_num[mid.0 as usize] = i as u32;
175    }
176
177    // Step 4: Cooper-Harvey-Kennedy iterative idom computation
178    let entry_idx = entry.0;
179    let mut idom = vec![u32::MAX; n];
180    idom[entry_idx as usize] = entry_idx;
181
182    let mut changed = true;
183    while changed {
184        changed = false;
185        for &mid in rpo.iter().skip(1) {
186            let idx = mid.0 as usize;
187            let mut new_idom: Option<u32> = None;
188            for &p in &preds[idx] {
189                if idom[p as usize] != u32::MAX {
190                    new_idom = Some(
191                        new_idom.map_or(p, |current| intersect_idom(&idom, &rpo_num, current, p)),
192                    );
193                }
194            }
195            if let Some(ni) = new_idom
196                && idom[idx] != ni
197            {
198                idom[idx] = ni;
199                changed = true;
200            }
201        }
202    }
203
204    // Step 5: Build dominator tree children
205    let mut children: Vec<Vec<u32>> = vec![Vec::new(); n];
206    for &mid in &rpo {
207        let idx = mid.0 as usize;
208        let dom = idom[idx];
209        if dom != u32::MAX && dom != idx as u32 {
210            children[dom as usize].push(idx as u32);
211        }
212    }
213
214    // Step 6: Post-order DFS of dominator tree to accumulate subtree sums
215    let mut weights = vec![0u64; n];
216    let mut stack: Vec<(u32, bool)> = vec![(entry_idx, false)];
217    while let Some((node, post_visit)) = stack.pop() {
218        if post_visit {
219            weights[node as usize] = graph.modules[node as usize].size_bytes;
220            for &child in &children[node as usize] {
221                weights[node as usize] += weights[child as usize];
222            }
223            continue;
224        }
225        stack.push((node, true));
226        for &child in &children[node as usize] {
227            stack.push((child, false));
228        }
229    }
230
231    weights
232}
233
234struct BfsResult {
235    static_set: Vec<ModuleId>,
236    dynamic_set: Vec<ModuleId>,
237    /// BFS parent pointers from the static traversal. parent[i] is the
238    /// predecessor of module i on the shortest static path from entry.
239    /// Entry and unreachable modules have `u32::MAX`.
240    static_parent: Vec<u32>,
241}
242
243/// BFS from entry point, collecting all reachable modules.
244/// Also records parent pointers during the static phase for chain reconstruction.
245fn bfs_reachable(graph: &ModuleGraph, entry: ModuleId) -> BfsResult {
246    let n = graph.modules.len();
247    let mut visited = vec![false; n];
248    let mut parent = vec![u32::MAX; n];
249    let mut static_set: Vec<ModuleId> = Vec::new();
250    let mut queue: VecDeque<ModuleId> = VecDeque::new();
251
252    visited[entry.0 as usize] = true;
253    static_set.push(entry);
254    queue.push_back(entry);
255
256    // BFS following static edges
257    while let Some(mid) = queue.pop_front() {
258        for &edge_id in graph.outgoing_edges(mid) {
259            let edge = graph.edge(edge_id);
260            let idx = edge.to.0 as usize;
261            if edge.kind == EdgeKind::Static && !visited[idx] {
262                visited[idx] = true;
263                parent[idx] = mid.0;
264                static_set.push(edge.to);
265                queue.push_back(edge.to);
266            }
267        }
268    }
269
270    // BFS following dynamic edges from all statically reachable modules
271    let mut dynamic_set: Vec<ModuleId> = Vec::new();
272    let mut dyn_queue: VecDeque<ModuleId> = VecDeque::new();
273
274    for &mid in &static_set {
275        for &edge_id in graph.outgoing_edges(mid) {
276            let edge = graph.edge(edge_id);
277            let idx = edge.to.0 as usize;
278            if edge.kind == EdgeKind::Dynamic && !visited[idx] {
279                visited[idx] = true;
280                dynamic_set.push(edge.to);
281                dyn_queue.push_back(edge.to);
282            }
283        }
284    }
285
286    // Continue BFS from dynamic-only modules (they may have static imports of their own)
287    while let Some(mid) = dyn_queue.pop_front() {
288        for &edge_id in graph.outgoing_edges(mid) {
289            let edge = graph.edge(edge_id);
290            let idx = edge.to.0 as usize;
291            if (edge.kind == EdgeKind::Static || edge.kind == EdgeKind::Dynamic) && !visited[idx] {
292                visited[idx] = true;
293                dynamic_set.push(edge.to);
294                dyn_queue.push_back(edge.to);
295            }
296        }
297    }
298
299    BfsResult {
300        static_set,
301        dynamic_set,
302        static_parent: parent,
303    }
304}
305
306/// Reconstruct the shortest chain from entry to target using pre-computed
307/// BFS parent pointers. Returns empty vec if target is unreachable.
308fn reconstruct_chain(parent: &[u32], entry: ModuleId, target: ModuleId) -> Vec<ModuleId> {
309    let mut chain = vec![target];
310    let mut current = target.0;
311    while current != entry.0 {
312        let p = parent[current as usize];
313        if p == u32::MAX {
314            return Vec::new();
315        }
316        chain.push(ModuleId(p));
317        current = p;
318    }
319    chain.reverse();
320    chain
321}
322
323#[must_use]
324#[allow(clippy::cast_sign_loss)]
325pub fn trace(graph: &ModuleGraph, entry: ModuleId, opts: &TraceOptions) -> TraceResult {
326    let bfs = bfs_reachable(graph, entry);
327    let mut reachable = bfs.static_set;
328    let dynamic_only = bfs.dynamic_set;
329
330    // When --include-dynamic is set, fold dynamic modules into the reachable
331    // set. There's nothing "only dynamic" when the user asked to include them.
332    // Compute dynamic-only packages before potentially merging sets
333    let mut dynamic_pkg_sizes: HashMap<String, u64> = HashMap::new();
334    for &mid in &dynamic_only {
335        let module = graph.module(mid);
336        if let Some(ref pkg) = module.package {
337            *dynamic_pkg_sizes.entry(pkg.clone()).or_default() += module.size_bytes;
338        }
339    }
340
341    let (dynamic_only_weight, dynamic_only_module_count) = if opts.include_dynamic {
342        reachable.extend_from_slice(&dynamic_only);
343        (0, 0)
344    } else {
345        let w: u64 = dynamic_only
346            .iter()
347            .map(|&mid| graph.module(mid).size_bytes)
348            .sum();
349        (w, dynamic_only.len())
350    };
351
352    let static_weight: u64 = reachable
353        .iter()
354        .map(|&mid| graph.module(mid).size_bytes)
355        .sum();
356
357    // Find heavy packages in the reachable set, tracking the first module
358    // encountered per package (BFS order = shortest distance from entry).
359    let mut package_sizes: HashMap<String, (u64, u32)> = HashMap::new();
360    let mut package_nearest: HashMap<String, ModuleId> = HashMap::new();
361    for &mid in &reachable {
362        let module = graph.module(mid);
363        if let Some(ref pkg) = module.package {
364            let e = package_sizes.entry(pkg.clone()).or_default();
365            e.0 += module.size_bytes;
366            e.1 += 1;
367            package_nearest.entry(pkg.clone()).or_insert(mid);
368        }
369    }
370
371    let all_packages: HashMap<String, u64> = package_sizes
372        .iter()
373        .map(|(k, (size, _))| (k.clone(), *size))
374        .collect();
375
376    // Sort and truncate BEFORE computing chains (each chain is a full BFS)
377    let mut sorted_packages: Vec<(String, u64, u32)> = package_sizes
378        .into_iter()
379        .map(|(name, (total_size, file_count))| (name, total_size, file_count))
380        .collect();
381    sorted_packages.sort_by(|a, b| b.1.cmp(&a.1));
382    if !opts.ignore.is_empty() {
383        sorted_packages.retain(|(name, _, _)| !opts.ignore.iter().any(|i| i == name));
384    }
385    if opts.top_n >= 0 {
386        sorted_packages.truncate(opts.top_n as usize);
387    }
388    let heavy_packages: Vec<HeavyPackage> = sorted_packages
389        .into_iter()
390        .map(|(name, total_size, file_count)| {
391            let chain = match package_nearest.get(&name) {
392                Some(&nearest) => reconstruct_chain(&bfs.static_parent, entry, nearest),
393                None => Vec::new(),
394            };
395            HeavyPackage {
396                name,
397                total_size,
398                file_count,
399                chain,
400            }
401        })
402        .collect();
403
404    // Compute exclusive weight for all reachable modules via dominator tree
405    let exclusive = compute_exclusive_weights(graph, entry, opts.include_dynamic);
406
407    // Prefer first-party (no package) modules for the per-file breakdown.
408    // Fall back to all modules when no first-party modules exist (e.g. Python
409    // projects where every reachable module is a third-party package).
410    let mut modules_by_cost: Vec<ModuleCost> = reachable
411        .iter()
412        .filter(|&&mid| mid != entry && graph.module(mid).package.is_none())
413        .map(|&mid| ModuleCost {
414            module_id: mid,
415            exclusive_size: exclusive[mid.0 as usize],
416        })
417        .collect();
418    if modules_by_cost.is_empty() {
419        modules_by_cost = reachable
420            .iter()
421            .filter(|&&mid| mid != entry)
422            .map(|&mid| ModuleCost {
423                module_id: mid,
424                exclusive_size: exclusive[mid.0 as usize],
425            })
426            .collect();
427    }
428
429    modules_by_cost.sort_by(|a, b| b.exclusive_size.cmp(&a.exclusive_size));
430
431    TraceResult {
432        static_weight,
433        static_module_count: reachable.len(),
434        dynamic_only_weight,
435        dynamic_only_module_count,
436        heavy_packages,
437        modules_by_cost,
438        all_packages,
439        dynamic_packages: dynamic_pkg_sizes,
440    }
441}
442
443/// Find ALL shortest chains from entry to a specific target (package or module).
444///
445/// Returns up to `max_chains` distinct shortest paths (all same hop count),
446/// deduplicated at the package-name level so chains that differ only by
447/// internal file paths are collapsed into one.
448#[must_use]
449pub fn find_all_chains(
450    graph: &ModuleGraph,
451    entry: ModuleId,
452    target: &ChainTarget,
453    include_dynamic: bool,
454) -> Vec<Vec<ModuleId>> {
455    let raw = all_shortest_chains(graph, entry, target, 10, include_dynamic);
456    dedup_chains_by_package(graph, raw)
457}
458
459/// Deduplicate chains that look identical at the package-name level.
460/// Two chains that differ only by which internal file within a package
461/// they pass through will have the same package-level key and only the
462/// first is kept.
463fn dedup_chains_by_package(graph: &ModuleGraph, chains: Vec<Vec<ModuleId>>) -> Vec<Vec<ModuleId>> {
464    let mut seen: HashSet<Vec<String>> = HashSet::new();
465    let mut result = Vec::new();
466
467    for chain in chains {
468        let key: Vec<String> = chain
469            .iter()
470            .map(|&mid| {
471                let m = graph.module(mid);
472                m.package
473                    .clone()
474                    .unwrap_or_else(|| m.path.to_string_lossy().into_owned())
475            })
476            .collect();
477
478        if seen.insert(key) {
479            result.push(chain);
480        }
481    }
482
483    result
484}
485
486/// BFS with multi-parent tracking to find all shortest paths to a target.
487fn all_shortest_chains(
488    graph: &ModuleGraph,
489    entry: ModuleId,
490    target: &ChainTarget,
491    max_chains: usize,
492    include_dynamic: bool,
493) -> Vec<Vec<ModuleId>> {
494    let n = graph.modules.len();
495    let mut parents: Vec<Vec<u32>> = vec![Vec::new(); n];
496    let mut depth: Vec<u32> = vec![u32::MAX; n];
497    let mut queue: VecDeque<ModuleId> = VecDeque::new();
498
499    depth[entry.0 as usize] = 0;
500    queue.push_back(entry);
501
502    let mut target_depth: Option<u32> = None;
503    let mut targets: Vec<ModuleId> = Vec::new();
504
505    while let Some(mid) = queue.pop_front() {
506        let d = depth[mid.0 as usize];
507
508        // If we've found targets and moved past their depth, stop
509        if let Some(td) = target_depth
510            && d > td
511        {
512            break;
513        }
514
515        // Check if this module matches the target
516        if target.matches(graph, mid) {
517            if target_depth.is_none() {
518                target_depth = Some(d);
519            }
520            targets.push(mid);
521            continue; // Don't expand past target package modules
522        }
523
524        for &edge_id in graph.outgoing_edges(mid) {
525            let edge = graph.edge(edge_id);
526            match edge.kind {
527                EdgeKind::Static => {}
528                EdgeKind::Dynamic if include_dynamic => {}
529                _ => continue,
530            }
531
532            let next_depth = d + 1;
533            let idx = edge.to.0 as usize;
534            match depth[idx] {
535                d if d == next_depth => {
536                    // Same depth -- add as alternate parent
537                    parents[idx].push(mid.0);
538                }
539                u32::MAX => {
540                    // First visit
541                    depth[idx] = next_depth;
542                    parents[idx].push(mid.0);
543                    queue.push_back(edge.to);
544                }
545                _ => {} // Already visited at shorter depth, skip
546            }
547        }
548    }
549
550    if targets.is_empty() {
551        return Vec::new();
552    }
553
554    // Backtrack from each target to reconstruct all paths
555    let mut all_chains: Vec<Vec<ModuleId>> = Vec::new();
556    for &target_mid in &targets {
557        let mut partial_paths: Vec<Vec<ModuleId>> = vec![vec![target_mid]];
558
559        loop {
560            let mut next_partial: Vec<Vec<ModuleId>> = Vec::new();
561            let mut any_extended = false;
562
563            for path in &partial_paths {
564                let &head = path.last().unwrap();
565                if head == entry {
566                    next_partial.push(path.clone());
567                    continue;
568                }
569                let pars = &parents[head.0 as usize];
570                if !pars.is_empty() {
571                    any_extended = true;
572                    for &p in pars {
573                        let mut new_path = path.clone();
574                        new_path.push(ModuleId(p));
575                        next_partial.push(new_path);
576                        if next_partial.len() > max_chains * 2 {
577                            break; // Prevent combinatorial explosion
578                        }
579                    }
580                }
581            }
582
583            partial_paths = next_partial;
584            if !any_extended || partial_paths.len() > max_chains * 2 {
585                break;
586            }
587        }
588
589        for mut path in partial_paths {
590            path.reverse();
591            if path.first() == Some(&entry) {
592                all_chains.push(path);
593                if all_chains.len() >= max_chains {
594                    return all_chains;
595                }
596            }
597        }
598    }
599
600    all_chains
601}
602
603/// A module whose dynamic conversion would sever one or more import chains.
604#[derive(Debug, Clone, Copy)]
605#[non_exhaustive]
606pub struct CutModule {
607    pub module_id: ModuleId,
608    pub chains_broken: usize,
609    pub exclusive_size: u64,
610}
611
612/// Find modules that appear in all chains from entry to a package.
613///
614/// Removing any one of these severs every import path to the target.
615/// Sorted by exclusive weight descending (highest-impact first),
616/// truncated to `top_n`.
617#[must_use]
618#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
619pub fn find_cut_modules(
620    graph: &ModuleGraph,
621    chains: &[Vec<ModuleId>],
622    entry: ModuleId,
623    target: &ChainTarget,
624    top_n: i32,
625    include_dynamic: bool,
626) -> Vec<CutModule> {
627    if chains.is_empty() {
628        return Vec::new();
629    }
630
631    let exclusive = compute_exclusive_weights(graph, entry, include_dynamic);
632
633    let total = chains.len();
634    let mut frequency = vec![0usize; graph.modules.len()];
635    for chain in chains {
636        for &mid in chain {
637            frequency[mid.0 as usize] += 1;
638        }
639    }
640
641    let mut cuts: Vec<CutModule> = frequency
642        .iter()
643        .enumerate()
644        .filter(|&(idx, &count)| {
645            let mid = ModuleId(idx as u32);
646            count == total && mid != entry && !target.matches(graph, mid)
647        })
648        .map(|(idx, &count)| CutModule {
649            module_id: ModuleId(idx as u32),
650            chains_broken: count,
651            exclusive_size: exclusive[idx],
652        })
653        .collect();
654
655    // Deduplicate at package level -- multiple files within the same
656    // node_modules package are the same cut point from the user's perspective.
657    // Sort descending first so we keep the highest-weight entry per package.
658    cuts.sort_by(|a, b| b.exclusive_size.cmp(&a.exclusive_size));
659    let mut seen_packages: HashSet<String> = HashSet::new();
660    cuts.retain(|c| {
661        let m = graph.module(c.module_id);
662        m.package
663            .as_ref()
664            .is_none_or(|pkg| seen_packages.insert(pkg.clone()))
665    });
666
667    // Single chain: sort ascending (most surgical cut first).
668    // Multiple chains: sort descending (highest-impact convergence point first).
669    if chains.len() == 1 {
670        cuts.sort_by(|a, b| a.exclusive_size.cmp(&b.exclusive_size));
671    }
672
673    if top_n >= 0 {
674        cuts.truncate(top_n as usize);
675    }
676    cuts
677}
678
679/// Minimal snapshot of a trace result for before/after comparison.
680#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
681#[non_exhaustive]
682pub struct TraceSnapshot {
683    pub entry: String,
684    pub static_weight: u64,
685    pub packages: HashMap<String, u64>,
686    #[serde(default)]
687    pub dynamic_weight: u64,
688    #[serde(default)]
689    pub dynamic_packages: HashMap<String, u64>,
690}
691
692impl TraceResult {
693    pub fn to_snapshot(&self, entry: &str) -> TraceSnapshot {
694        TraceSnapshot {
695            entry: entry.to_string(),
696            static_weight: self.static_weight,
697            packages: self.all_packages.clone(),
698            dynamic_weight: self.dynamic_only_weight,
699            dynamic_packages: self.dynamic_packages.clone(),
700        }
701    }
702}
703
704/// A package that appears in only one side of a diff, with its size.
705#[derive(Debug)]
706#[non_exhaustive]
707pub struct DiffPackage {
708    pub name: String,
709    pub size: u64,
710}
711
712/// Compute a diff between two trace snapshots.
713#[derive(Debug)]
714#[non_exhaustive]
715pub struct DiffResult {
716    pub entry_a_weight: u64,
717    pub entry_b_weight: u64,
718    pub weight_delta: i64,
719    pub dynamic_a_weight: u64,
720    pub dynamic_b_weight: u64,
721    pub dynamic_weight_delta: i64,
722    pub shared_count: usize,
723    pub only_in_a: Vec<DiffPackage>,
724    pub only_in_b: Vec<DiffPackage>,
725    pub dynamic_only_in_a: Vec<DiffPackage>,
726    pub dynamic_only_in_b: Vec<DiffPackage>,
727}
728
729#[must_use]
730#[allow(clippy::cast_possible_wrap)]
731pub fn diff_snapshots(a: &TraceSnapshot, b: &TraceSnapshot) -> DiffResult {
732    let keys_a: HashSet<&str> = a.packages.keys().map(String::as_str).collect();
733    let keys_b: HashSet<&str> = b.packages.keys().map(String::as_str).collect();
734
735    let mut only_in_a: Vec<DiffPackage> = keys_a
736        .difference(&keys_b)
737        .map(|&name| DiffPackage {
738            name: name.to_string(),
739            size: a.packages[name],
740        })
741        .collect();
742    only_in_a.sort_by(|x, y| y.size.cmp(&x.size));
743
744    let mut only_in_b: Vec<DiffPackage> = keys_b
745        .difference(&keys_a)
746        .map(|&name| DiffPackage {
747            name: name.to_string(),
748            size: b.packages[name],
749        })
750        .collect();
751    only_in_b.sort_by(|x, y| y.size.cmp(&x.size));
752
753    let dyn_keys_a: HashSet<&str> = a.dynamic_packages.keys().map(String::as_str).collect();
754    let dyn_keys_b: HashSet<&str> = b.dynamic_packages.keys().map(String::as_str).collect();
755
756    let mut dynamic_only_in_a: Vec<DiffPackage> = dyn_keys_a
757        .difference(&dyn_keys_b)
758        .map(|&name| DiffPackage {
759            name: name.to_string(),
760            size: a.dynamic_packages[name],
761        })
762        .collect();
763    dynamic_only_in_a.sort_by(|x, y| y.size.cmp(&x.size));
764
765    let mut dynamic_only_in_b: Vec<DiffPackage> = dyn_keys_b
766        .difference(&dyn_keys_a)
767        .map(|&name| DiffPackage {
768            name: name.to_string(),
769            size: b.dynamic_packages[name],
770        })
771        .collect();
772    dynamic_only_in_b.sort_by(|x, y| y.size.cmp(&x.size));
773
774    DiffResult {
775        entry_a_weight: a.static_weight,
776        entry_b_weight: b.static_weight,
777        weight_delta: b.static_weight as i64 - a.static_weight as i64,
778        dynamic_a_weight: a.dynamic_weight,
779        dynamic_b_weight: b.dynamic_weight,
780        dynamic_weight_delta: b.dynamic_weight as i64 - a.dynamic_weight as i64,
781        shared_count: keys_a.intersection(&keys_b).count(),
782        only_in_a,
783        only_in_b,
784        dynamic_only_in_a,
785        dynamic_only_in_b,
786    }
787}
788
789#[cfg(test)]
790mod tests {
791    use super::*;
792    use crate::graph::ModuleGraph;
793    use std::path::PathBuf;
794
795    /// Build a small graph from declarative specs.
796    /// `nodes`: `(path, size_bytes, package_name)`
797    /// `edges`: `(from_index, to_index, kind)`
798    fn make_graph(
799        nodes: &[(&str, u64, Option<&str>)],
800        edges: &[(usize, usize, EdgeKind)],
801    ) -> ModuleGraph {
802        let mut graph = ModuleGraph::new();
803        for &(path, size, pkg) in nodes {
804            graph.add_module(PathBuf::from(path), size, pkg.map(str::to_string));
805        }
806        for &(from, to, kind) in edges {
807            #[allow(clippy::cast_possible_truncation)]
808            graph.add_edge(ModuleId(from as u32), ModuleId(to as u32), kind, "");
809        }
810        graph
811    }
812
813    // --- BFS / trace ---
814
815    #[test]
816    fn trace_static_weight() {
817        // A(100) -> B(200) -> C(300)
818        let graph = make_graph(
819            &[
820                ("a.ts", 100, None),
821                ("b.ts", 200, None),
822                ("c.ts", 300, None),
823            ],
824            &[(0, 1, EdgeKind::Static), (1, 2, EdgeKind::Static)],
825        );
826        let result = trace(&graph, ModuleId(0), &TraceOptions::default());
827        assert_eq!(result.static_weight, 600);
828        assert_eq!(result.static_module_count, 3);
829    }
830
831    #[test]
832    fn trace_dynamic_excluded_by_default() {
833        // A(100) -static-> B(200), A -dynamic-> C(300)
834        let graph = make_graph(
835            &[
836                ("a.ts", 100, None),
837                ("b.ts", 200, None),
838                ("c.ts", 300, None),
839            ],
840            &[(0, 1, EdgeKind::Static), (0, 2, EdgeKind::Dynamic)],
841        );
842        let result = trace(&graph, ModuleId(0), &TraceOptions::default());
843        assert_eq!(result.static_weight, 300); // A + B only
844        assert_eq!(result.dynamic_only_weight, 300); // C
845        assert_eq!(result.dynamic_only_module_count, 1);
846    }
847
848    #[test]
849    fn trace_include_dynamic() {
850        // A(100) -dynamic-> B(200)
851        let graph = make_graph(
852            &[("a.ts", 100, None), ("b.ts", 200, None)],
853            &[(0, 1, EdgeKind::Dynamic)],
854        );
855        let opts = TraceOptions {
856            include_dynamic: true,
857            top_n: 10,
858            ignore: Vec::new(),
859        };
860        let result = trace(&graph, ModuleId(0), &opts);
861        // B should appear in modules_by_cost when include_dynamic is set
862        assert!(
863            result
864                .modules_by_cost
865                .iter()
866                .any(|m| m.module_id == ModuleId(1))
867        );
868    }
869
870    // --- Chain finding ---
871
872    #[test]
873    fn chain_linear_path() {
874        // A -> B -> C -> zod
875        let graph = make_graph(
876            &[
877                ("a.ts", 100, None),
878                ("b.ts", 100, None),
879                ("c.ts", 100, None),
880                ("node_modules/zod/index.js", 500, Some("zod")),
881            ],
882            &[
883                (0, 1, EdgeKind::Static),
884                (1, 2, EdgeKind::Static),
885                (2, 3, EdgeKind::Static),
886            ],
887        );
888        let chains = find_all_chains(
889            &graph,
890            ModuleId(0),
891            &ChainTarget::Package("zod".to_string()),
892            false,
893        );
894        assert_eq!(chains.len(), 1);
895        assert_eq!(
896            chains[0],
897            vec![ModuleId(0), ModuleId(1), ModuleId(2), ModuleId(3)]
898        );
899    }
900
901    #[test]
902    fn chain_dedup_same_package_path() {
903        // Two paths to zod that differ only by internal zod file:
904        // A -> B -> zod/index.js
905        // A -> B -> zod/lib.js
906        // These should dedup to one chain at the package level.
907        let graph = make_graph(
908            &[
909                ("a.ts", 100, None),
910                ("b.ts", 100, None),
911                ("node_modules/zod/index.js", 250, Some("zod")),
912                ("node_modules/zod/lib.js", 250, Some("zod")),
913            ],
914            &[
915                (0, 1, EdgeKind::Static),
916                (1, 2, EdgeKind::Static),
917                (1, 3, EdgeKind::Static),
918            ],
919        );
920        let chains = find_all_chains(
921            &graph,
922            ModuleId(0),
923            &ChainTarget::Package("zod".to_string()),
924            false,
925        );
926        assert_eq!(chains.len(), 1);
927    }
928
929    #[test]
930    fn chain_not_reachable() {
931        // A -> B, no path to zod
932        let graph = make_graph(
933            &[
934                ("a.ts", 100, None),
935                ("b.ts", 100, None),
936                ("node_modules/zod/index.js", 500, Some("zod")),
937            ],
938            &[(0, 1, EdgeKind::Static)],
939        );
940        let chains = find_all_chains(
941            &graph,
942            ModuleId(0),
943            &ChainTarget::Package("zod".to_string()),
944            false,
945        );
946        assert!(chains.is_empty());
947    }
948
949    #[test]
950    fn chain_through_dynamic_edge() {
951        // A -dynamic-> B -static-> zod
952        // Without include_dynamic: no chain. With: chain found.
953        let graph = make_graph(
954            &[
955                ("a.ts", 100, None),
956                ("b.ts", 100, None),
957                ("node_modules/zod/index.js", 500, Some("zod")),
958            ],
959            &[(0, 1, EdgeKind::Dynamic), (1, 2, EdgeKind::Static)],
960        );
961        let chains_static = find_all_chains(
962            &graph,
963            ModuleId(0),
964            &ChainTarget::Package("zod".to_string()),
965            false,
966        );
967        assert!(chains_static.is_empty());
968
969        let chains_dynamic = find_all_chains(
970            &graph,
971            ModuleId(0),
972            &ChainTarget::Package("zod".to_string()),
973            true,
974        );
975        assert_eq!(chains_dynamic.len(), 1);
976        assert_eq!(
977            chains_dynamic[0],
978            vec![ModuleId(0), ModuleId(1), ModuleId(2)]
979        );
980    }
981
982    // --- Cut points ---
983
984    #[test]
985    fn cut_single_convergence_point() {
986        // Diamond: A -> B -> D -> zod
987        //          A -> C -> D -> zod
988        // D is the cut point (appears in all chains)
989        let graph = make_graph(
990            &[
991                ("a.ts", 100, None),
992                ("b.ts", 100, None),
993                ("c.ts", 100, None),
994                ("d.ts", 100, None),
995                ("node_modules/zod/index.js", 500, Some("zod")),
996            ],
997            &[
998                (0, 1, EdgeKind::Static),
999                (0, 2, EdgeKind::Static),
1000                (1, 3, EdgeKind::Static),
1001                (2, 3, EdgeKind::Static),
1002                (3, 4, EdgeKind::Static),
1003            ],
1004        );
1005        let target = ChainTarget::Package("zod".to_string());
1006        let chains = find_all_chains(&graph, ModuleId(0), &target, false);
1007        assert_eq!(chains.len(), 2);
1008
1009        let cuts = find_cut_modules(&graph, &chains, ModuleId(0), &target, 10, false);
1010        assert!(!cuts.is_empty());
1011        assert!(cuts.iter().any(|c| c.module_id == ModuleId(3)));
1012    }
1013
1014    #[test]
1015    fn cut_no_convergence_point() {
1016        // Two independent paths:
1017        // A -> B -> zod1
1018        // A -> C -> zod2
1019        // No module (other than A and zod) appears in all chains.
1020        let graph = make_graph(
1021            &[
1022                ("a.ts", 100, None),
1023                ("b.ts", 100, None),
1024                ("c.ts", 100, None),
1025                ("node_modules/zod/index.js", 250, Some("zod")),
1026                ("node_modules/zod/lib.js", 250, Some("zod")),
1027            ],
1028            &[
1029                (0, 1, EdgeKind::Static),
1030                (0, 2, EdgeKind::Static),
1031                (1, 3, EdgeKind::Static),
1032                (2, 4, EdgeKind::Static),
1033            ],
1034        );
1035        let target = ChainTarget::Package("zod".to_string());
1036        let chains = find_all_chains(&graph, ModuleId(0), &target, false);
1037        let cuts = find_cut_modules(&graph, &chains, ModuleId(0), &target, 10, false);
1038        assert!(cuts.is_empty());
1039    }
1040
1041    #[test]
1042    fn cut_direct_import_no_intermediate() {
1043        // Entry directly imports target: A -> zod (1 hop)
1044        // No intermediate module exists to cut.
1045        let graph = make_graph(
1046            &[
1047                ("a.ts", 100, None),
1048                ("node_modules/zod/index.js", 500, Some("zod")),
1049            ],
1050            &[(0, 1, EdgeKind::Static)],
1051        );
1052        let target = ChainTarget::Package("zod".to_string());
1053        let chains = find_all_chains(&graph, ModuleId(0), &target, false);
1054        assert_eq!(chains.len(), 1);
1055        assert_eq!(chains[0].len(), 2); // 1 hop = 2 nodes
1056
1057        let cuts = find_cut_modules(&graph, &chains, ModuleId(0), &target, 10, false);
1058        assert!(cuts.is_empty());
1059    }
1060
1061    #[test]
1062    fn cut_single_chain_ascending_sort() {
1063        // Single chain: A -> B(big) -> C(small) -> zod
1064        // With single chain, cuts should sort ascending (surgical first).
1065        let graph = make_graph(
1066            &[
1067                ("a.ts", 100, None),
1068                ("b.ts", 5000, None),
1069                ("c.ts", 100, None),
1070                ("node_modules/zod/index.js", 500, Some("zod")),
1071            ],
1072            &[
1073                (0, 1, EdgeKind::Static),
1074                (1, 2, EdgeKind::Static),
1075                (2, 3, EdgeKind::Static),
1076            ],
1077        );
1078        let target = ChainTarget::Package("zod".to_string());
1079        let chains = find_all_chains(&graph, ModuleId(0), &target, false);
1080        assert_eq!(chains.len(), 1);
1081
1082        let cuts = find_cut_modules(&graph, &chains, ModuleId(0), &target, 10, false);
1083        assert!(cuts.len() >= 2);
1084        // First cut should have smaller exclusive_size (more surgical)
1085        assert!(cuts[0].exclusive_size <= cuts[1].exclusive_size);
1086    }
1087
1088    // --- Diff ---
1089
1090    fn snap(entry: &str, static_weight: u64, packages: &[(&str, u64)]) -> TraceSnapshot {
1091        TraceSnapshot {
1092            entry: entry.to_string(),
1093            static_weight,
1094            packages: packages
1095                .iter()
1096                .map(|(k, v)| ((*k).to_string(), *v))
1097                .collect(),
1098            dynamic_weight: 0,
1099            dynamic_packages: HashMap::new(),
1100        }
1101    }
1102
1103    fn snap_with_dynamic(
1104        entry: &str,
1105        static_weight: u64,
1106        packages: &[(&str, u64)],
1107        dynamic_weight: u64,
1108        dynamic_packages: &[(&str, u64)],
1109    ) -> TraceSnapshot {
1110        TraceSnapshot {
1111            entry: entry.to_string(),
1112            static_weight,
1113            packages: packages
1114                .iter()
1115                .map(|(k, v)| ((*k).to_string(), *v))
1116                .collect(),
1117            dynamic_weight,
1118            dynamic_packages: dynamic_packages
1119                .iter()
1120                .map(|(k, v)| ((*k).to_string(), *v))
1121                .collect(),
1122        }
1123    }
1124
1125    #[test]
1126    fn diff_snapshots_computes_sets() {
1127        let a = snap(
1128            "a.ts",
1129            1000,
1130            &[("zod", 500), ("chalk", 200), ("tslog", 300)],
1131        );
1132        let b = snap("b.ts", 800, &[("chalk", 200), ("tslog", 300), ("ajv", 100)]);
1133        let diff = diff_snapshots(&a, &b);
1134
1135        assert_eq!(diff.entry_a_weight, 1000);
1136        assert_eq!(diff.entry_b_weight, 800);
1137        assert_eq!(diff.weight_delta, -200);
1138        assert_eq!(diff.only_in_a.len(), 1);
1139        assert_eq!(diff.only_in_a[0].name, "zod");
1140        assert_eq!(diff.only_in_a[0].size, 500);
1141        assert_eq!(diff.only_in_b.len(), 1);
1142        assert_eq!(diff.only_in_b[0].name, "ajv");
1143        assert_eq!(diff.only_in_b[0].size, 100);
1144        assert_eq!(diff.shared_count, 2);
1145    }
1146
1147    #[test]
1148    fn diff_snapshots_sorted_by_size_descending() {
1149        let a = snap(
1150            "a.ts",
1151            1000,
1152            &[("small", 10), ("big", 500), ("medium", 100)],
1153        );
1154        let b = snap("b.ts", 0, &[]);
1155        let diff = diff_snapshots(&a, &b);
1156
1157        assert_eq!(diff.only_in_a.len(), 3);
1158        assert_eq!(diff.only_in_a[0].name, "big");
1159        assert_eq!(diff.only_in_a[1].name, "medium");
1160        assert_eq!(diff.only_in_a[2].name, "small");
1161    }
1162
1163    #[test]
1164    fn diff_snapshots_dynamic_packages() {
1165        let a = snap_with_dynamic("a.ts", 1000, &[("zod", 500)], 200, &[("lodash", 200)]);
1166        let b = snap_with_dynamic("b.ts", 800, &[("zod", 500)], 350, &[("moment", 350)]);
1167        let diff = diff_snapshots(&a, &b);
1168
1169        assert_eq!(diff.dynamic_a_weight, 200);
1170        assert_eq!(diff.dynamic_b_weight, 350);
1171        assert_eq!(diff.dynamic_weight_delta, 150);
1172        assert_eq!(diff.dynamic_only_in_a.len(), 1);
1173        assert_eq!(diff.dynamic_only_in_a[0].name, "lodash");
1174        assert_eq!(diff.dynamic_only_in_b.len(), 1);
1175        assert_eq!(diff.dynamic_only_in_b[0].name, "moment");
1176    }
1177
1178    // --- Exclusive weight ---
1179
1180    #[test]
1181    fn exclusive_weight_linear_chain() {
1182        // Entry(100) -> A(200) -> B(300)
1183        // No sharing: exclusive == full subtree
1184        let graph = make_graph(
1185            &[
1186                ("entry.ts", 100, None),
1187                ("a.ts", 200, None),
1188                ("b.ts", 300, None),
1189            ],
1190            &[(0, 1, EdgeKind::Static), (1, 2, EdgeKind::Static)],
1191        );
1192        let weights = compute_exclusive_weights(&graph, ModuleId(0), false);
1193        assert_eq!(weights[0], 600); // entry: entire graph
1194        assert_eq!(weights[1], 500); // a: a + b
1195        assert_eq!(weights[2], 300); // b: just b
1196    }
1197
1198    #[test]
1199    fn exclusive_weight_diamond_shared() {
1200        // Entry(100) -> A(200) -> D(500)
1201        // Entry(100) -> B(300) -> D(500)
1202        // D is shared: not in A's or B's exclusive subtree
1203        let graph = make_graph(
1204            &[
1205                ("entry.ts", 100, None),
1206                ("a.ts", 200, None),
1207                ("b.ts", 300, None),
1208                ("d.ts", 500, None),
1209            ],
1210            &[
1211                (0, 1, EdgeKind::Static),
1212                (0, 2, EdgeKind::Static),
1213                (1, 3, EdgeKind::Static),
1214                (2, 3, EdgeKind::Static),
1215            ],
1216        );
1217        let weights = compute_exclusive_weights(&graph, ModuleId(0), false);
1218        assert_eq!(weights[0], 1100); // entry: everything
1219        assert_eq!(weights[1], 200); // a: only itself (D shared)
1220        assert_eq!(weights[2], 300); // b: only itself (D shared)
1221        assert_eq!(weights[3], 500); // d: only itself
1222    }
1223
1224    #[test]
1225    fn exclusive_weight_mixed_exclusive_and_shared() {
1226        // Entry(100) -> A(200) -> D(500)
1227        // Entry(100) -> B(300) -> D(500)
1228        // A(200) -> E(600)  -- E only reachable through A
1229        let graph = make_graph(
1230            &[
1231                ("entry.ts", 100, None),
1232                ("a.ts", 200, None),
1233                ("b.ts", 300, None),
1234                ("d.ts", 500, None),
1235                ("e.ts", 600, None),
1236            ],
1237            &[
1238                (0, 1, EdgeKind::Static),
1239                (0, 2, EdgeKind::Static),
1240                (1, 3, EdgeKind::Static),
1241                (2, 3, EdgeKind::Static),
1242                (1, 4, EdgeKind::Static),
1243            ],
1244        );
1245        let weights = compute_exclusive_weights(&graph, ModuleId(0), false);
1246        assert_eq!(weights[0], 1700); // entry: everything
1247        assert_eq!(weights[1], 800); // a: a(200) + e(600), not d
1248        assert_eq!(weights[2], 300); // b: only itself
1249        assert_eq!(weights[3], 500); // d: only itself (shared)
1250        assert_eq!(weights[4], 600); // e: only itself
1251    }
1252
1253    #[test]
1254    fn exclusive_weight_with_dynamic_edges() {
1255        // Entry(100) -static-> A(200) -static-> C(400)
1256        // Entry(100) -dynamic-> B(300) -static-> C(400)
1257        // Static only: C exclusively through A
1258        // With dynamic: C shared between A and B
1259        let graph = make_graph(
1260            &[
1261                ("entry.ts", 100, None),
1262                ("a.ts", 200, None),
1263                ("b.ts", 300, None),
1264                ("c.ts", 400, None),
1265            ],
1266            &[
1267                (0, 1, EdgeKind::Static),
1268                (0, 2, EdgeKind::Dynamic),
1269                (1, 3, EdgeKind::Static),
1270                (2, 3, EdgeKind::Static),
1271            ],
1272        );
1273        // Static only: B unreachable, C exclusively through A
1274        let static_weights = compute_exclusive_weights(&graph, ModuleId(0), false);
1275        assert_eq!(static_weights[1], 600); // a: a(200) + c(400)
1276
1277        // With dynamic: C shared between A and B
1278        let all_weights = compute_exclusive_weights(&graph, ModuleId(0), true);
1279        assert_eq!(all_weights[1], 200); // a: only itself (c shared with b)
1280        assert_eq!(all_weights[2], 300); // b: only itself (c shared with a)
1281    }
1282
1283    // --- Ignore filter ---
1284
1285    #[test]
1286    fn trace_ignore_filters_heavy_packages() {
1287        let graph = make_graph(
1288            &[
1289                ("entry.ts", 100, None),
1290                ("a.ts", 50, Some("pkg-a")),
1291                ("b.ts", 200, Some("pkg-b")),
1292                ("c.ts", 300, Some("pkg-c")),
1293            ],
1294            &[
1295                (0, 1, EdgeKind::Static),
1296                (0, 2, EdgeKind::Static),
1297                (0, 3, EdgeKind::Static),
1298            ],
1299        );
1300        let opts = TraceOptions {
1301            include_dynamic: false,
1302            top_n: 10,
1303            ignore: vec!["pkg-c".to_string()],
1304        };
1305        let result = trace(&graph, ModuleId(0), &opts);
1306        let names: Vec<&str> = result
1307            .heavy_packages
1308            .iter()
1309            .map(|p| p.name.as_str())
1310            .collect();
1311        assert!(names.contains(&"pkg-a"));
1312        assert!(names.contains(&"pkg-b"));
1313        assert!(!names.contains(&"pkg-c"));
1314    }
1315
1316    #[test]
1317    fn trace_ignore_does_not_affect_total_weight() {
1318        let graph = make_graph(
1319            &[("entry.ts", 100, None), ("a.ts", 500, Some("big-pkg"))],
1320            &[(0, 1, EdgeKind::Static)],
1321        );
1322        let opts = TraceOptions {
1323            include_dynamic: false,
1324            top_n: 10,
1325            ignore: vec!["big-pkg".to_string()],
1326        };
1327        let result = trace(&graph, ModuleId(0), &opts);
1328        assert!(result.heavy_packages.is_empty());
1329        assert_eq!(result.static_weight, 600);
1330    }
1331
1332    // --- ChainTarget::Module ---
1333
1334    #[test]
1335    fn chain_to_module_by_id() {
1336        let graph = make_graph(
1337            &[
1338                ("entry.ts", 100, None),
1339                ("a.ts", 50, None),
1340                ("b.ts", 200, None),
1341            ],
1342            &[(0, 1, EdgeKind::Static), (1, 2, EdgeKind::Static)],
1343        );
1344        let target_id = graph.path_to_id[&PathBuf::from("b.ts")];
1345        let chains = find_all_chains(&graph, ModuleId(0), &ChainTarget::Module(target_id), false);
1346        assert_eq!(chains.len(), 1);
1347        assert_eq!(chains[0].len(), 3);
1348        assert_eq!(*chains[0].last().unwrap(), target_id);
1349    }
1350
1351    #[test]
1352    fn cut_to_module_by_id() {
1353        let graph = make_graph(
1354            &[
1355                ("entry.ts", 100, None),
1356                ("bridge.ts", 50, None),
1357                ("target.ts", 200, None),
1358            ],
1359            &[(0, 1, EdgeKind::Static), (1, 2, EdgeKind::Static)],
1360        );
1361        let target_id = graph.path_to_id[&PathBuf::from("target.ts")];
1362        let target = ChainTarget::Module(target_id);
1363        let chains = find_all_chains(&graph, ModuleId(0), &target, false);
1364        let cuts = find_cut_modules(&graph, &chains, ModuleId(0), &target, 10, false);
1365        assert_eq!(cuts.len(), 1);
1366        assert_eq!(
1367            cuts[0].module_id,
1368            graph.path_to_id[&PathBuf::from("bridge.ts")]
1369        );
1370    }
1371
1372    // --- top_n negative/zero ---
1373
1374    #[test]
1375    fn trace_top_n_negative_shows_all() {
1376        let graph = make_graph(
1377            &[
1378                ("entry.ts", 10, None),
1379                ("a.ts", 10, Some("pkg-a")),
1380                ("b.ts", 10, Some("pkg-b")),
1381                ("c.ts", 10, Some("pkg-c")),
1382            ],
1383            &[
1384                (0, 1, EdgeKind::Static),
1385                (0, 2, EdgeKind::Static),
1386                (0, 3, EdgeKind::Static),
1387            ],
1388        );
1389        let opts = TraceOptions {
1390            top_n: -1,
1391            ..Default::default()
1392        };
1393        let result = trace(&graph, ModuleId(0), &opts);
1394        assert_eq!(result.heavy_packages.len(), 3);
1395    }
1396
1397    #[test]
1398    fn trace_top_n_zero_shows_none() {
1399        let graph = make_graph(
1400            &[("entry.ts", 10, None), ("a.ts", 10, Some("pkg-a"))],
1401            &[(0, 1, EdgeKind::Static)],
1402        );
1403        let opts = TraceOptions {
1404            top_n: 0,
1405            ..Default::default()
1406        };
1407        let result = trace(&graph, ModuleId(0), &opts);
1408        assert_eq!(result.heavy_packages.len(), 0);
1409    }
1410}