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)]
158pub(crate) fn 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.
487#[allow(clippy::too_many_lines)]
488fn all_shortest_chains(
489    graph: &ModuleGraph,
490    entry: ModuleId,
491    target: &ChainTarget,
492    max_chains: usize,
493    include_dynamic: bool,
494) -> Vec<Vec<ModuleId>> {
495    let n = graph.modules.len();
496    let mut parents: Vec<Vec<u32>> = vec![Vec::new(); n];
497    let mut depth: Vec<u32> = vec![u32::MAX; n];
498    let mut queue: VecDeque<ModuleId> = VecDeque::new();
499
500    depth[entry.0 as usize] = 0;
501    queue.push_back(entry);
502
503    let mut target_depth: Option<u32> = None;
504    let mut targets: Vec<ModuleId> = Vec::new();
505
506    while let Some(mid) = queue.pop_front() {
507        let d = depth[mid.0 as usize];
508
509        // If we've found targets and moved past their depth, stop
510        if let Some(td) = target_depth
511            && d > td
512        {
513            break;
514        }
515
516        // Check if this module matches the target
517        if target.matches(graph, mid) {
518            if target_depth.is_none() {
519                target_depth = Some(d);
520            }
521            targets.push(mid);
522            continue; // Don't expand past target package modules
523        }
524
525        for &edge_id in graph.outgoing_edges(mid) {
526            let edge = graph.edge(edge_id);
527            match edge.kind {
528                EdgeKind::Static => {}
529                EdgeKind::Dynamic if include_dynamic => {}
530                _ => continue,
531            }
532
533            let next_depth = d + 1;
534            let idx = edge.to.0 as usize;
535            match depth[idx] {
536                d if d == next_depth => {
537                    // Same depth -- add as alternate parent
538                    parents[idx].push(mid.0);
539                }
540                u32::MAX => {
541                    // First visit
542                    depth[idx] = next_depth;
543                    parents[idx].push(mid.0);
544                    queue.push_back(edge.to);
545                }
546                _ => {} // Already visited at shorter depth, skip
547            }
548        }
549    }
550
551    if targets.is_empty() {
552        return Vec::new();
553    }
554
555    // Backtrack from each target to reconstruct all paths.
556    // `limit` caps *expansion* per iteration (not total paths) —
557    // capped paths extend with a single parent to avoid discarding
558    // reachable paths. Total paths grow linearly with depth, not
559    // exponentially.
560    let limit = max_chains * 2;
561    let mut all_chains: Vec<Vec<ModuleId>> = Vec::new();
562    for &target_mid in &targets {
563        let mut partial_paths: Vec<Vec<ModuleId>> = vec![vec![target_mid]];
564
565        loop {
566            let mut next_partial: Vec<Vec<ModuleId>> = Vec::new();
567            let mut any_extended = false;
568            let mut capped = false;
569
570            for path in &partial_paths {
571                let &head = path.last().unwrap();
572                if head == entry {
573                    next_partial.push(path.clone());
574                    continue;
575                }
576                if capped {
577                    // Over budget — only keep the first partial for this branch
578                    // so it can still reach entry in future iterations.
579                    let pars = &parents[head.0 as usize];
580                    if let Some(&p) = pars.first() {
581                        any_extended = true;
582                        let mut new_path = path.clone();
583                        new_path.push(ModuleId(p));
584                        next_partial.push(new_path);
585                    }
586                    continue;
587                }
588                let pars = &parents[head.0 as usize];
589                if !pars.is_empty() {
590                    any_extended = true;
591                    for &p in pars {
592                        let mut new_path = path.clone();
593                        new_path.push(ModuleId(p));
594                        next_partial.push(new_path);
595                        if next_partial.len() > limit {
596                            capped = true;
597                            break;
598                        }
599                    }
600                }
601            }
602
603            partial_paths = next_partial;
604            if !any_extended {
605                break;
606            }
607        }
608
609        for mut path in partial_paths {
610            path.reverse();
611            if path.first() == Some(&entry) {
612                all_chains.push(path);
613                if all_chains.len() >= max_chains {
614                    return all_chains;
615                }
616            }
617        }
618    }
619
620    all_chains
621}
622
623/// A module whose dynamic conversion would sever one or more import chains.
624#[derive(Debug, Clone, Copy)]
625#[non_exhaustive]
626pub struct CutModule {
627    pub module_id: ModuleId,
628    pub chains_broken: usize,
629    pub exclusive_size: u64,
630}
631
632/// Find modules that appear in all chains from entry to a package.
633///
634/// Removing any one of these severs every import path to the target.
635/// Sorted by exclusive weight descending (highest-impact first),
636/// truncated to `top_n`.
637#[must_use]
638#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
639pub fn find_cut_modules(
640    graph: &ModuleGraph,
641    chains: &[Vec<ModuleId>],
642    entry: ModuleId,
643    target: &ChainTarget,
644    top_n: i32,
645    exclusive_weights: &[u64],
646) -> Vec<CutModule> {
647    if chains.is_empty() {
648        return Vec::new();
649    }
650
651    let total = chains.len();
652    let mut frequency = vec![0usize; graph.modules.len()];
653    for chain in chains {
654        for &mid in chain {
655            frequency[mid.0 as usize] += 1;
656        }
657    }
658
659    let mut cuts: Vec<CutModule> = frequency
660        .iter()
661        .enumerate()
662        .filter(|&(idx, &count)| {
663            let mid = ModuleId(idx as u32);
664            count == total && mid != entry && !target.matches(graph, mid)
665        })
666        .map(|(idx, &count)| CutModule {
667            module_id: ModuleId(idx as u32),
668            chains_broken: count,
669            exclusive_size: exclusive_weights[idx],
670        })
671        .collect();
672
673    // Deduplicate at package level -- multiple files within the same
674    // node_modules package are the same cut point from the user's perspective.
675    // Sort descending first so we keep the highest-weight entry per package.
676    cuts.sort_by(|a, b| b.exclusive_size.cmp(&a.exclusive_size));
677    let mut seen_packages: HashSet<String> = HashSet::new();
678    cuts.retain(|c| {
679        let m = graph.module(c.module_id);
680        m.package
681            .as_ref()
682            .is_none_or(|pkg| seen_packages.insert(pkg.clone()))
683    });
684
685    // Single chain: sort ascending (most surgical cut first).
686    // Multiple chains: sort descending (highest-impact convergence point first).
687    if chains.len() == 1 {
688        cuts.sort_by(|a, b| a.exclusive_size.cmp(&b.exclusive_size));
689    }
690
691    if top_n >= 0 {
692        cuts.truncate(top_n as usize);
693    }
694    cuts
695}
696
697/// Minimal snapshot of a trace result for before/after comparison.
698#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
699#[non_exhaustive]
700pub struct TraceSnapshot {
701    pub entry: String,
702    pub static_weight: u64,
703    pub packages: HashMap<String, u64>,
704    #[serde(default)]
705    pub dynamic_weight: u64,
706    #[serde(default)]
707    pub dynamic_packages: HashMap<String, u64>,
708}
709
710impl TraceResult {
711    pub fn to_snapshot(&self, entry: &str) -> TraceSnapshot {
712        TraceSnapshot {
713            entry: entry.to_string(),
714            static_weight: self.static_weight,
715            packages: self.all_packages.clone(),
716            dynamic_weight: self.dynamic_only_weight,
717            dynamic_packages: self.dynamic_packages.clone(),
718        }
719    }
720}
721
722/// A package that appears in only one side of a diff, with its size.
723#[derive(Debug)]
724#[non_exhaustive]
725pub struct DiffPackage {
726    pub name: String,
727    pub size: u64,
728}
729
730/// Compute a diff between two trace snapshots.
731#[derive(Debug)]
732#[non_exhaustive]
733pub struct DiffResult {
734    pub entry_a_weight: u64,
735    pub entry_b_weight: u64,
736    pub weight_delta: i64,
737    pub dynamic_a_weight: u64,
738    pub dynamic_b_weight: u64,
739    pub dynamic_weight_delta: i64,
740    pub shared_count: usize,
741    pub only_in_a: Vec<DiffPackage>,
742    pub only_in_b: Vec<DiffPackage>,
743    pub dynamic_only_in_a: Vec<DiffPackage>,
744    pub dynamic_only_in_b: Vec<DiffPackage>,
745}
746
747#[must_use]
748#[allow(clippy::cast_possible_wrap)]
749pub fn diff_snapshots(a: &TraceSnapshot, b: &TraceSnapshot) -> DiffResult {
750    let keys_a: HashSet<&str> = a.packages.keys().map(String::as_str).collect();
751    let keys_b: HashSet<&str> = b.packages.keys().map(String::as_str).collect();
752
753    let mut only_in_a: Vec<DiffPackage> = keys_a
754        .difference(&keys_b)
755        .map(|&name| DiffPackage {
756            name: name.to_string(),
757            size: a.packages[name],
758        })
759        .collect();
760    only_in_a.sort_by(|x, y| y.size.cmp(&x.size));
761
762    let mut only_in_b: Vec<DiffPackage> = keys_b
763        .difference(&keys_a)
764        .map(|&name| DiffPackage {
765            name: name.to_string(),
766            size: b.packages[name],
767        })
768        .collect();
769    only_in_b.sort_by(|x, y| y.size.cmp(&x.size));
770
771    let dyn_keys_a: HashSet<&str> = a.dynamic_packages.keys().map(String::as_str).collect();
772    let dyn_keys_b: HashSet<&str> = b.dynamic_packages.keys().map(String::as_str).collect();
773
774    let mut dynamic_only_in_a: Vec<DiffPackage> = dyn_keys_a
775        .difference(&dyn_keys_b)
776        .map(|&name| DiffPackage {
777            name: name.to_string(),
778            size: a.dynamic_packages[name],
779        })
780        .collect();
781    dynamic_only_in_a.sort_by(|x, y| y.size.cmp(&x.size));
782
783    let mut dynamic_only_in_b: Vec<DiffPackage> = dyn_keys_b
784        .difference(&dyn_keys_a)
785        .map(|&name| DiffPackage {
786            name: name.to_string(),
787            size: b.dynamic_packages[name],
788        })
789        .collect();
790    dynamic_only_in_b.sort_by(|x, y| y.size.cmp(&x.size));
791
792    DiffResult {
793        entry_a_weight: a.static_weight,
794        entry_b_weight: b.static_weight,
795        weight_delta: b.static_weight as i64 - a.static_weight as i64,
796        dynamic_a_weight: a.dynamic_weight,
797        dynamic_b_weight: b.dynamic_weight,
798        dynamic_weight_delta: b.dynamic_weight as i64 - a.dynamic_weight as i64,
799        shared_count: keys_a.intersection(&keys_b).count(),
800        only_in_a,
801        only_in_b,
802        dynamic_only_in_a,
803        dynamic_only_in_b,
804    }
805}
806
807#[cfg(test)]
808mod tests {
809    use super::*;
810    use crate::graph::ModuleGraph;
811    use std::path::PathBuf;
812
813    /// Build a small graph from declarative specs.
814    /// `nodes`: `(path, size_bytes, package_name)`
815    /// `edges`: `(from_index, to_index, kind)`
816    fn make_graph(
817        nodes: &[(&str, u64, Option<&str>)],
818        edges: &[(usize, usize, EdgeKind)],
819    ) -> ModuleGraph {
820        let mut graph = ModuleGraph::new();
821        for &(path, size, pkg) in nodes {
822            graph.add_module(PathBuf::from(path), size, pkg.map(str::to_string));
823        }
824        for &(from, to, kind) in edges {
825            #[allow(clippy::cast_possible_truncation)]
826            graph.add_edge(ModuleId(from as u32), ModuleId(to as u32), kind, "");
827        }
828        graph
829    }
830
831    // --- BFS / trace ---
832
833    #[test]
834    fn trace_static_weight() {
835        // A(100) -> B(200) -> C(300)
836        let graph = make_graph(
837            &[
838                ("a.ts", 100, None),
839                ("b.ts", 200, None),
840                ("c.ts", 300, None),
841            ],
842            &[(0, 1, EdgeKind::Static), (1, 2, EdgeKind::Static)],
843        );
844        let result = trace(&graph, ModuleId(0), &TraceOptions::default());
845        assert_eq!(result.static_weight, 600);
846        assert_eq!(result.static_module_count, 3);
847    }
848
849    #[test]
850    fn trace_dynamic_excluded_by_default() {
851        // A(100) -static-> B(200), A -dynamic-> C(300)
852        let graph = make_graph(
853            &[
854                ("a.ts", 100, None),
855                ("b.ts", 200, None),
856                ("c.ts", 300, None),
857            ],
858            &[(0, 1, EdgeKind::Static), (0, 2, EdgeKind::Dynamic)],
859        );
860        let result = trace(&graph, ModuleId(0), &TraceOptions::default());
861        assert_eq!(result.static_weight, 300); // A + B only
862        assert_eq!(result.dynamic_only_weight, 300); // C
863        assert_eq!(result.dynamic_only_module_count, 1);
864    }
865
866    #[test]
867    fn trace_include_dynamic() {
868        // A(100) -dynamic-> B(200)
869        let graph = make_graph(
870            &[("a.ts", 100, None), ("b.ts", 200, None)],
871            &[(0, 1, EdgeKind::Dynamic)],
872        );
873        let opts = TraceOptions {
874            include_dynamic: true,
875            top_n: 10,
876            ignore: Vec::new(),
877        };
878        let result = trace(&graph, ModuleId(0), &opts);
879        // B should appear in modules_by_cost when include_dynamic is set
880        assert!(
881            result
882                .modules_by_cost
883                .iter()
884                .any(|m| m.module_id == ModuleId(1))
885        );
886    }
887
888    // --- Chain finding ---
889
890    #[test]
891    fn chain_linear_path() {
892        // A -> B -> C -> zod
893        let graph = make_graph(
894            &[
895                ("a.ts", 100, None),
896                ("b.ts", 100, None),
897                ("c.ts", 100, None),
898                ("node_modules/zod/index.js", 500, Some("zod")),
899            ],
900            &[
901                (0, 1, EdgeKind::Static),
902                (1, 2, EdgeKind::Static),
903                (2, 3, EdgeKind::Static),
904            ],
905        );
906        let chains = find_all_chains(
907            &graph,
908            ModuleId(0),
909            &ChainTarget::Package("zod".to_string()),
910            false,
911        );
912        assert_eq!(chains.len(), 1);
913        assert_eq!(
914            chains[0],
915            vec![ModuleId(0), ModuleId(1), ModuleId(2), ModuleId(3)]
916        );
917    }
918
919    #[test]
920    fn chain_dedup_same_package_path() {
921        // Two paths to zod that differ only by internal zod file:
922        // A -> B -> zod/index.js
923        // A -> B -> zod/lib.js
924        // These should dedup to one chain at the package level.
925        let graph = make_graph(
926            &[
927                ("a.ts", 100, None),
928                ("b.ts", 100, None),
929                ("node_modules/zod/index.js", 250, Some("zod")),
930                ("node_modules/zod/lib.js", 250, Some("zod")),
931            ],
932            &[
933                (0, 1, EdgeKind::Static),
934                (1, 2, EdgeKind::Static),
935                (1, 3, EdgeKind::Static),
936            ],
937        );
938        let chains = find_all_chains(
939            &graph,
940            ModuleId(0),
941            &ChainTarget::Package("zod".to_string()),
942            false,
943        );
944        assert_eq!(chains.len(), 1);
945    }
946
947    #[test]
948    fn chain_not_reachable() {
949        // A -> B, no path to zod
950        let graph = make_graph(
951            &[
952                ("a.ts", 100, None),
953                ("b.ts", 100, None),
954                ("node_modules/zod/index.js", 500, Some("zod")),
955            ],
956            &[(0, 1, EdgeKind::Static)],
957        );
958        let chains = find_all_chains(
959            &graph,
960            ModuleId(0),
961            &ChainTarget::Package("zod".to_string()),
962            false,
963        );
964        assert!(chains.is_empty());
965    }
966
967    #[test]
968    fn chain_through_dynamic_edge() {
969        // A -dynamic-> B -static-> zod
970        // Without include_dynamic: no chain. With: chain found.
971        let graph = make_graph(
972            &[
973                ("a.ts", 100, None),
974                ("b.ts", 100, None),
975                ("node_modules/zod/index.js", 500, Some("zod")),
976            ],
977            &[(0, 1, EdgeKind::Dynamic), (1, 2, EdgeKind::Static)],
978        );
979        let chains_static = find_all_chains(
980            &graph,
981            ModuleId(0),
982            &ChainTarget::Package("zod".to_string()),
983            false,
984        );
985        assert!(chains_static.is_empty());
986
987        let chains_dynamic = find_all_chains(
988            &graph,
989            ModuleId(0),
990            &ChainTarget::Package("zod".to_string()),
991            true,
992        );
993        assert_eq!(chains_dynamic.len(), 1);
994        assert_eq!(
995            chains_dynamic[0],
996            vec![ModuleId(0), ModuleId(1), ModuleId(2)]
997        );
998    }
999
1000    #[test]
1001    #[allow(clippy::cast_possible_truncation)]
1002    fn chain_high_fanout_not_dropped() {
1003        // Bug #172: When a target has many parents (>max_chains*2), the
1004        // explosion guard in backtracking fires before any path reaches
1005        // the entry, causing all paths to be discarded as incomplete.
1006        //
1007        // Graph: entry -> hub -> {f0, f1, ..., f24} -> target(pkg=zod)
1008        // Every fi fans into the same target, so target has 25 parents.
1009        // BFS finds target at depth 3. Backtracking from target sees 25
1010        // parents, exceeds max_chains*2=20, and breaks before any path
1011        // completes back to entry.
1012        // Build graph programmatically: entry -> hub -> {f0..f24} -> target
1013        let fan = 25usize;
1014        let mut graph = ModuleGraph::new();
1015        graph.add_module(PathBuf::from("entry.ts"), 100, None); // 0
1016        graph.add_module(PathBuf::from("hub.ts"), 100, None); // 1
1017        for i in 0..fan {
1018            graph.add_module(PathBuf::from(format!("f{i}.ts")), 50, None); // 2..2+fan
1019        }
1020        let target_idx = (2 + fan) as u32;
1021        graph.add_module(
1022            PathBuf::from("node_modules/zod/index.js"),
1023            500,
1024            Some("zod".to_string()),
1025        );
1026
1027        // entry -> hub
1028        graph.add_edge(ModuleId(0), ModuleId(1), EdgeKind::Static, "");
1029        for i in 0..fan {
1030            let fi = ModuleId((2 + i) as u32);
1031            graph.add_edge(ModuleId(1), fi, EdgeKind::Static, ""); // hub -> fi
1032            graph.add_edge(fi, ModuleId(target_idx), EdgeKind::Static, ""); // fi -> target
1033        }
1034
1035        let chains = find_all_chains(
1036            &graph,
1037            ModuleId(0),
1038            &ChainTarget::Package("zod".to_string()),
1039            false,
1040        );
1041        // Must find at least one chain — the target IS reachable
1042        assert!(
1043            !chains.is_empty(),
1044            "high-fanout target should still produce chains (got 0)"
1045        );
1046        // Every chain must start at entry and end at target
1047        for chain in &chains {
1048            assert_eq!(chain.first(), Some(&ModuleId(0)));
1049            assert_eq!(chain.last(), Some(&ModuleId(target_idx)));
1050        }
1051    }
1052
1053    // --- Cut points ---
1054
1055    #[test]
1056    fn cut_single_convergence_point() {
1057        // Diamond: A -> B -> D -> zod
1058        //          A -> C -> D -> zod
1059        // D is the cut point (appears in all chains)
1060        let graph = make_graph(
1061            &[
1062                ("a.ts", 100, None),
1063                ("b.ts", 100, None),
1064                ("c.ts", 100, None),
1065                ("d.ts", 100, None),
1066                ("node_modules/zod/index.js", 500, Some("zod")),
1067            ],
1068            &[
1069                (0, 1, EdgeKind::Static),
1070                (0, 2, EdgeKind::Static),
1071                (1, 3, EdgeKind::Static),
1072                (2, 3, EdgeKind::Static),
1073                (3, 4, EdgeKind::Static),
1074            ],
1075        );
1076        let target = ChainTarget::Package("zod".to_string());
1077        let chains = find_all_chains(&graph, ModuleId(0), &target, false);
1078        assert_eq!(chains.len(), 2);
1079
1080        let weights = compute_exclusive_weights(&graph, ModuleId(0), false);
1081        let cuts = find_cut_modules(&graph, &chains, ModuleId(0), &target, 10, &weights);
1082        assert!(!cuts.is_empty());
1083        assert!(cuts.iter().any(|c| c.module_id == ModuleId(3)));
1084    }
1085
1086    #[test]
1087    fn cut_no_convergence_point() {
1088        // Two independent paths:
1089        // A -> B -> zod1
1090        // A -> C -> zod2
1091        // No module (other than A and zod) appears in all chains.
1092        let graph = make_graph(
1093            &[
1094                ("a.ts", 100, None),
1095                ("b.ts", 100, None),
1096                ("c.ts", 100, None),
1097                ("node_modules/zod/index.js", 250, Some("zod")),
1098                ("node_modules/zod/lib.js", 250, Some("zod")),
1099            ],
1100            &[
1101                (0, 1, EdgeKind::Static),
1102                (0, 2, EdgeKind::Static),
1103                (1, 3, EdgeKind::Static),
1104                (2, 4, EdgeKind::Static),
1105            ],
1106        );
1107        let target = ChainTarget::Package("zod".to_string());
1108        let chains = find_all_chains(&graph, ModuleId(0), &target, false);
1109        let weights = compute_exclusive_weights(&graph, ModuleId(0), false);
1110        let cuts = find_cut_modules(&graph, &chains, ModuleId(0), &target, 10, &weights);
1111        assert!(cuts.is_empty());
1112    }
1113
1114    #[test]
1115    fn cut_direct_import_no_intermediate() {
1116        // Entry directly imports target: A -> zod (1 hop)
1117        // No intermediate module exists to cut.
1118        let graph = make_graph(
1119            &[
1120                ("a.ts", 100, None),
1121                ("node_modules/zod/index.js", 500, Some("zod")),
1122            ],
1123            &[(0, 1, EdgeKind::Static)],
1124        );
1125        let target = ChainTarget::Package("zod".to_string());
1126        let chains = find_all_chains(&graph, ModuleId(0), &target, false);
1127        assert_eq!(chains.len(), 1);
1128        assert_eq!(chains[0].len(), 2); // 1 hop = 2 nodes
1129
1130        let weights = compute_exclusive_weights(&graph, ModuleId(0), false);
1131        let cuts = find_cut_modules(&graph, &chains, ModuleId(0), &target, 10, &weights);
1132        assert!(cuts.is_empty());
1133    }
1134
1135    #[test]
1136    fn cut_single_chain_ascending_sort() {
1137        // Single chain: A -> B(big) -> C(small) -> zod
1138        // With single chain, cuts should sort ascending (surgical first).
1139        let graph = make_graph(
1140            &[
1141                ("a.ts", 100, None),
1142                ("b.ts", 5000, None),
1143                ("c.ts", 100, None),
1144                ("node_modules/zod/index.js", 500, Some("zod")),
1145            ],
1146            &[
1147                (0, 1, EdgeKind::Static),
1148                (1, 2, EdgeKind::Static),
1149                (2, 3, EdgeKind::Static),
1150            ],
1151        );
1152        let target = ChainTarget::Package("zod".to_string());
1153        let chains = find_all_chains(&graph, ModuleId(0), &target, false);
1154        assert_eq!(chains.len(), 1);
1155
1156        let weights = compute_exclusive_weights(&graph, ModuleId(0), false);
1157        let cuts = find_cut_modules(&graph, &chains, ModuleId(0), &target, 10, &weights);
1158        assert!(cuts.len() >= 2);
1159        // First cut should have smaller exclusive_size (more surgical)
1160        assert!(cuts[0].exclusive_size <= cuts[1].exclusive_size);
1161    }
1162
1163    // --- Diff ---
1164
1165    fn snap(entry: &str, static_weight: u64, packages: &[(&str, u64)]) -> TraceSnapshot {
1166        TraceSnapshot {
1167            entry: entry.to_string(),
1168            static_weight,
1169            packages: packages
1170                .iter()
1171                .map(|(k, v)| ((*k).to_string(), *v))
1172                .collect(),
1173            dynamic_weight: 0,
1174            dynamic_packages: HashMap::new(),
1175        }
1176    }
1177
1178    fn snap_with_dynamic(
1179        entry: &str,
1180        static_weight: u64,
1181        packages: &[(&str, u64)],
1182        dynamic_weight: u64,
1183        dynamic_packages: &[(&str, u64)],
1184    ) -> TraceSnapshot {
1185        TraceSnapshot {
1186            entry: entry.to_string(),
1187            static_weight,
1188            packages: packages
1189                .iter()
1190                .map(|(k, v)| ((*k).to_string(), *v))
1191                .collect(),
1192            dynamic_weight,
1193            dynamic_packages: dynamic_packages
1194                .iter()
1195                .map(|(k, v)| ((*k).to_string(), *v))
1196                .collect(),
1197        }
1198    }
1199
1200    #[test]
1201    fn diff_snapshots_computes_sets() {
1202        let a = snap(
1203            "a.ts",
1204            1000,
1205            &[("zod", 500), ("chalk", 200), ("tslog", 300)],
1206        );
1207        let b = snap("b.ts", 800, &[("chalk", 200), ("tslog", 300), ("ajv", 100)]);
1208        let diff = diff_snapshots(&a, &b);
1209
1210        assert_eq!(diff.entry_a_weight, 1000);
1211        assert_eq!(diff.entry_b_weight, 800);
1212        assert_eq!(diff.weight_delta, -200);
1213        assert_eq!(diff.only_in_a.len(), 1);
1214        assert_eq!(diff.only_in_a[0].name, "zod");
1215        assert_eq!(diff.only_in_a[0].size, 500);
1216        assert_eq!(diff.only_in_b.len(), 1);
1217        assert_eq!(diff.only_in_b[0].name, "ajv");
1218        assert_eq!(diff.only_in_b[0].size, 100);
1219        assert_eq!(diff.shared_count, 2);
1220    }
1221
1222    #[test]
1223    fn diff_snapshots_sorted_by_size_descending() {
1224        let a = snap(
1225            "a.ts",
1226            1000,
1227            &[("small", 10), ("big", 500), ("medium", 100)],
1228        );
1229        let b = snap("b.ts", 0, &[]);
1230        let diff = diff_snapshots(&a, &b);
1231
1232        assert_eq!(diff.only_in_a.len(), 3);
1233        assert_eq!(diff.only_in_a[0].name, "big");
1234        assert_eq!(diff.only_in_a[1].name, "medium");
1235        assert_eq!(diff.only_in_a[2].name, "small");
1236    }
1237
1238    #[test]
1239    fn diff_snapshots_dynamic_packages() {
1240        let a = snap_with_dynamic("a.ts", 1000, &[("zod", 500)], 200, &[("lodash", 200)]);
1241        let b = snap_with_dynamic("b.ts", 800, &[("zod", 500)], 350, &[("moment", 350)]);
1242        let diff = diff_snapshots(&a, &b);
1243
1244        assert_eq!(diff.dynamic_a_weight, 200);
1245        assert_eq!(diff.dynamic_b_weight, 350);
1246        assert_eq!(diff.dynamic_weight_delta, 150);
1247        assert_eq!(diff.dynamic_only_in_a.len(), 1);
1248        assert_eq!(diff.dynamic_only_in_a[0].name, "lodash");
1249        assert_eq!(diff.dynamic_only_in_b.len(), 1);
1250        assert_eq!(diff.dynamic_only_in_b[0].name, "moment");
1251    }
1252
1253    // --- Exclusive weight ---
1254
1255    #[test]
1256    fn exclusive_weight_linear_chain() {
1257        // Entry(100) -> A(200) -> B(300)
1258        // No sharing: exclusive == full subtree
1259        let graph = make_graph(
1260            &[
1261                ("entry.ts", 100, None),
1262                ("a.ts", 200, None),
1263                ("b.ts", 300, None),
1264            ],
1265            &[(0, 1, EdgeKind::Static), (1, 2, EdgeKind::Static)],
1266        );
1267        let weights = compute_exclusive_weights(&graph, ModuleId(0), false);
1268        assert_eq!(weights[0], 600); // entry: entire graph
1269        assert_eq!(weights[1], 500); // a: a + b
1270        assert_eq!(weights[2], 300); // b: just b
1271    }
1272
1273    #[test]
1274    fn exclusive_weight_diamond_shared() {
1275        // Entry(100) -> A(200) -> D(500)
1276        // Entry(100) -> B(300) -> D(500)
1277        // D is shared: not in A's or B's exclusive subtree
1278        let graph = make_graph(
1279            &[
1280                ("entry.ts", 100, None),
1281                ("a.ts", 200, None),
1282                ("b.ts", 300, None),
1283                ("d.ts", 500, None),
1284            ],
1285            &[
1286                (0, 1, EdgeKind::Static),
1287                (0, 2, EdgeKind::Static),
1288                (1, 3, EdgeKind::Static),
1289                (2, 3, EdgeKind::Static),
1290            ],
1291        );
1292        let weights = compute_exclusive_weights(&graph, ModuleId(0), false);
1293        assert_eq!(weights[0], 1100); // entry: everything
1294        assert_eq!(weights[1], 200); // a: only itself (D shared)
1295        assert_eq!(weights[2], 300); // b: only itself (D shared)
1296        assert_eq!(weights[3], 500); // d: only itself
1297    }
1298
1299    #[test]
1300    fn exclusive_weight_mixed_exclusive_and_shared() {
1301        // Entry(100) -> A(200) -> D(500)
1302        // Entry(100) -> B(300) -> D(500)
1303        // A(200) -> E(600)  -- E only reachable through A
1304        let graph = make_graph(
1305            &[
1306                ("entry.ts", 100, None),
1307                ("a.ts", 200, None),
1308                ("b.ts", 300, None),
1309                ("d.ts", 500, None),
1310                ("e.ts", 600, None),
1311            ],
1312            &[
1313                (0, 1, EdgeKind::Static),
1314                (0, 2, EdgeKind::Static),
1315                (1, 3, EdgeKind::Static),
1316                (2, 3, EdgeKind::Static),
1317                (1, 4, EdgeKind::Static),
1318            ],
1319        );
1320        let weights = compute_exclusive_weights(&graph, ModuleId(0), false);
1321        assert_eq!(weights[0], 1700); // entry: everything
1322        assert_eq!(weights[1], 800); // a: a(200) + e(600), not d
1323        assert_eq!(weights[2], 300); // b: only itself
1324        assert_eq!(weights[3], 500); // d: only itself (shared)
1325        assert_eq!(weights[4], 600); // e: only itself
1326    }
1327
1328    #[test]
1329    fn exclusive_weight_with_dynamic_edges() {
1330        // Entry(100) -static-> A(200) -static-> C(400)
1331        // Entry(100) -dynamic-> B(300) -static-> C(400)
1332        // Static only: C exclusively through A
1333        // With dynamic: C shared between A and B
1334        let graph = make_graph(
1335            &[
1336                ("entry.ts", 100, None),
1337                ("a.ts", 200, None),
1338                ("b.ts", 300, None),
1339                ("c.ts", 400, None),
1340            ],
1341            &[
1342                (0, 1, EdgeKind::Static),
1343                (0, 2, EdgeKind::Dynamic),
1344                (1, 3, EdgeKind::Static),
1345                (2, 3, EdgeKind::Static),
1346            ],
1347        );
1348        // Static only: B unreachable, C exclusively through A
1349        let static_weights = compute_exclusive_weights(&graph, ModuleId(0), false);
1350        assert_eq!(static_weights[1], 600); // a: a(200) + c(400)
1351
1352        // With dynamic: C shared between A and B
1353        let all_weights = compute_exclusive_weights(&graph, ModuleId(0), true);
1354        assert_eq!(all_weights[1], 200); // a: only itself (c shared with b)
1355        assert_eq!(all_weights[2], 300); // b: only itself (c shared with a)
1356    }
1357
1358    // --- Ignore filter ---
1359
1360    #[test]
1361    fn trace_ignore_filters_heavy_packages() {
1362        let graph = make_graph(
1363            &[
1364                ("entry.ts", 100, None),
1365                ("a.ts", 50, Some("pkg-a")),
1366                ("b.ts", 200, Some("pkg-b")),
1367                ("c.ts", 300, Some("pkg-c")),
1368            ],
1369            &[
1370                (0, 1, EdgeKind::Static),
1371                (0, 2, EdgeKind::Static),
1372                (0, 3, EdgeKind::Static),
1373            ],
1374        );
1375        let opts = TraceOptions {
1376            include_dynamic: false,
1377            top_n: 10,
1378            ignore: vec!["pkg-c".to_string()],
1379        };
1380        let result = trace(&graph, ModuleId(0), &opts);
1381        let names: Vec<&str> = result
1382            .heavy_packages
1383            .iter()
1384            .map(|p| p.name.as_str())
1385            .collect();
1386        assert!(names.contains(&"pkg-a"));
1387        assert!(names.contains(&"pkg-b"));
1388        assert!(!names.contains(&"pkg-c"));
1389    }
1390
1391    #[test]
1392    fn trace_ignore_does_not_affect_total_weight() {
1393        let graph = make_graph(
1394            &[("entry.ts", 100, None), ("a.ts", 500, Some("big-pkg"))],
1395            &[(0, 1, EdgeKind::Static)],
1396        );
1397        let opts = TraceOptions {
1398            include_dynamic: false,
1399            top_n: 10,
1400            ignore: vec!["big-pkg".to_string()],
1401        };
1402        let result = trace(&graph, ModuleId(0), &opts);
1403        assert!(result.heavy_packages.is_empty());
1404        assert_eq!(result.static_weight, 600);
1405    }
1406
1407    // --- ChainTarget::Module ---
1408
1409    #[test]
1410    fn chain_to_module_by_id() {
1411        let graph = make_graph(
1412            &[
1413                ("entry.ts", 100, None),
1414                ("a.ts", 50, None),
1415                ("b.ts", 200, None),
1416            ],
1417            &[(0, 1, EdgeKind::Static), (1, 2, EdgeKind::Static)],
1418        );
1419        let target_id = graph.path_to_id[&PathBuf::from("b.ts")];
1420        let chains = find_all_chains(&graph, ModuleId(0), &ChainTarget::Module(target_id), false);
1421        assert_eq!(chains.len(), 1);
1422        assert_eq!(chains[0].len(), 3);
1423        assert_eq!(*chains[0].last().unwrap(), target_id);
1424    }
1425
1426    #[test]
1427    fn cut_to_module_by_id() {
1428        let graph = make_graph(
1429            &[
1430                ("entry.ts", 100, None),
1431                ("bridge.ts", 50, None),
1432                ("target.ts", 200, None),
1433            ],
1434            &[(0, 1, EdgeKind::Static), (1, 2, EdgeKind::Static)],
1435        );
1436        let target_id = graph.path_to_id[&PathBuf::from("target.ts")];
1437        let target = ChainTarget::Module(target_id);
1438        let chains = find_all_chains(&graph, ModuleId(0), &target, false);
1439        let weights = compute_exclusive_weights(&graph, ModuleId(0), false);
1440        let cuts = find_cut_modules(&graph, &chains, ModuleId(0), &target, 10, &weights);
1441        assert_eq!(cuts.len(), 1);
1442        assert_eq!(
1443            cuts[0].module_id,
1444            graph.path_to_id[&PathBuf::from("bridge.ts")]
1445        );
1446    }
1447
1448    // --- top_n negative/zero ---
1449
1450    #[test]
1451    fn trace_top_n_negative_shows_all() {
1452        let graph = make_graph(
1453            &[
1454                ("entry.ts", 10, None),
1455                ("a.ts", 10, Some("pkg-a")),
1456                ("b.ts", 10, Some("pkg-b")),
1457                ("c.ts", 10, Some("pkg-c")),
1458            ],
1459            &[
1460                (0, 1, EdgeKind::Static),
1461                (0, 2, EdgeKind::Static),
1462                (0, 3, EdgeKind::Static),
1463            ],
1464        );
1465        let opts = TraceOptions {
1466            top_n: -1,
1467            ..Default::default()
1468        };
1469        let result = trace(&graph, ModuleId(0), &opts);
1470        assert_eq!(result.heavy_packages.len(), 3);
1471    }
1472
1473    #[test]
1474    fn trace_top_n_zero_shows_none() {
1475        let graph = make_graph(
1476            &[("entry.ts", 10, None), ("a.ts", 10, Some("pkg-a"))],
1477            &[(0, 1, EdgeKind::Static)],
1478        );
1479        let opts = TraceOptions {
1480            top_n: 0,
1481            ..Default::default()
1482        };
1483        let result = trace(&graph, ModuleId(0), &opts);
1484        assert_eq!(result.heavy_packages.len(), 0);
1485    }
1486}