1use std::collections::{HashMap, HashSet, VecDeque};
4
5use serde::{Deserialize, Serialize};
6
7use crate::graph::{EdgeKind, ModuleGraph, ModuleId};
8
9#[derive(Debug)]
11#[non_exhaustive]
12pub struct TraceResult {
13 pub static_weight: u64,
15 pub static_module_count: usize,
17 pub dynamic_only_weight: u64,
19 pub dynamic_only_module_count: usize,
21 pub heavy_packages: Vec<HeavyPackage>,
23 pub modules_by_cost: Vec<ModuleCost>,
25 pub all_packages: HashMap<String, u64>,
27 pub dynamic_packages: HashMap<String, u64>,
29}
30
31#[derive(Debug)]
33#[non_exhaustive]
34pub struct HeavyPackage {
35 pub name: String,
36 pub total_size: u64,
37 pub file_count: u32,
38 pub chain: Vec<ModuleId>,
40}
41
42#[derive(Debug, Clone, Copy)]
44#[non_exhaustive]
45pub struct ModuleCost {
46 pub module_id: ModuleId,
47 pub exclusive_size: u64,
48}
49
50#[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#[derive(Debug, Clone, PartialEq)]
70#[non_exhaustive]
71pub enum ChainTarget {
72 Package(String),
74 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
87const 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
96fn 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 preds[to_idx].push(mid.0);
126 } else {
127 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
139fn 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#[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 let (rpo, preds) = reverse_postorder_with_preds(graph, entry, include_dynamic);
167 if rpo.is_empty() {
168 return vec![0; n];
169 }
170
171 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 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(new_idom.map_or(p, |current| {
191 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 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 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 static_parent: Vec<u32>,
241}
242
243fn bfs_reachable(
246 graph: &ModuleGraph,
247 entry: ModuleId,
248) -> BfsResult {
249 let n = graph.modules.len();
250 let mut visited = vec![false; n];
251 let mut parent = vec![u32::MAX; n];
252 let mut static_set: Vec<ModuleId> = Vec::new();
253 let mut queue: VecDeque<ModuleId> = VecDeque::new();
254
255 visited[entry.0 as usize] = true;
256 static_set.push(entry);
257 queue.push_back(entry);
258
259 while let Some(mid) = queue.pop_front() {
261 for &edge_id in graph.outgoing_edges(mid) {
262 let edge = graph.edge(edge_id);
263 let idx = edge.to.0 as usize;
264 if edge.kind == EdgeKind::Static && !visited[idx] {
265 visited[idx] = true;
266 parent[idx] = mid.0;
267 static_set.push(edge.to);
268 queue.push_back(edge.to);
269 }
270 }
271 }
272
273 let mut dynamic_set: Vec<ModuleId> = Vec::new();
275 let mut dyn_queue: VecDeque<ModuleId> = VecDeque::new();
276
277 for &mid in &static_set {
278 for &edge_id in graph.outgoing_edges(mid) {
279 let edge = graph.edge(edge_id);
280 let idx = edge.to.0 as usize;
281 if edge.kind == EdgeKind::Dynamic && !visited[idx] {
282 visited[idx] = true;
283 dynamic_set.push(edge.to);
284 dyn_queue.push_back(edge.to);
285 }
286 }
287 }
288
289 while let Some(mid) = dyn_queue.pop_front() {
291 for &edge_id in graph.outgoing_edges(mid) {
292 let edge = graph.edge(edge_id);
293 let idx = edge.to.0 as usize;
294 if (edge.kind == EdgeKind::Static || edge.kind == EdgeKind::Dynamic)
295 && !visited[idx]
296 {
297 visited[idx] = true;
298 dynamic_set.push(edge.to);
299 dyn_queue.push_back(edge.to);
300 }
301 }
302 }
303
304 BfsResult { static_set, dynamic_set, static_parent: parent }
305}
306
307fn reconstruct_chain(parent: &[u32], entry: ModuleId, target: ModuleId) -> Vec<ModuleId> {
310 let mut chain = vec![target];
311 let mut current = target.0;
312 while current != entry.0 {
313 let p = parent[current as usize];
314 if p == u32::MAX {
315 return Vec::new();
316 }
317 chain.push(ModuleId(p));
318 current = p;
319 }
320 chain.reverse();
321 chain
322}
323
324#[must_use]
325#[allow(clippy::cast_sign_loss)]
326pub fn trace(graph: &ModuleGraph, entry: ModuleId, opts: &TraceOptions) -> TraceResult {
327 let bfs = bfs_reachable(graph, entry);
328 let mut reachable = bfs.static_set;
329 let dynamic_only = bfs.dynamic_set;
330
331 let mut dynamic_pkg_sizes: HashMap<String, u64> = HashMap::new();
335 for &mid in &dynamic_only {
336 let module = graph.module(mid);
337 if let Some(ref pkg) = module.package {
338 *dynamic_pkg_sizes.entry(pkg.clone()).or_default() += module.size_bytes;
339 }
340 }
341
342 let (dynamic_only_weight, dynamic_only_module_count) = if opts.include_dynamic {
343 reachable.extend_from_slice(&dynamic_only);
344 (0, 0)
345 } else {
346 let w: u64 = dynamic_only.iter().map(|&mid| graph.module(mid).size_bytes).sum();
347 (w, dynamic_only.len())
348 };
349
350 let static_weight: u64 = reachable
351 .iter()
352 .map(|&mid| graph.module(mid).size_bytes)
353 .sum();
354
355 let mut package_sizes: HashMap<String, (u64, u32)> = HashMap::new();
358 let mut package_nearest: HashMap<String, ModuleId> = HashMap::new();
359 for &mid in &reachable {
360 let module = graph.module(mid);
361 if let Some(ref pkg) = module.package {
362 let e = package_sizes.entry(pkg.clone()).or_default();
363 e.0 += module.size_bytes;
364 e.1 += 1;
365 package_nearest.entry(pkg.clone()).or_insert(mid);
366 }
367 }
368
369 let all_packages: HashMap<String, u64> =
370 package_sizes.iter().map(|(k, (size, _))| (k.clone(), *size)).collect();
371
372 let mut sorted_packages: Vec<(String, u64, u32)> = package_sizes
374 .into_iter()
375 .map(|(name, (total_size, file_count))| (name, total_size, file_count))
376 .collect();
377 sorted_packages.sort_by(|a, b| b.1.cmp(&a.1));
378 if !opts.ignore.is_empty() {
379 sorted_packages.retain(|(name, _, _)| !opts.ignore.iter().any(|i| i == name));
380 }
381 if opts.top_n >= 0 {
382 sorted_packages.truncate(opts.top_n as usize);
383 }
384 let heavy_packages: Vec<HeavyPackage> = sorted_packages
385 .into_iter()
386 .map(|(name, total_size, file_count)| {
387 let chain = match package_nearest.get(&name) {
388 Some(&nearest) => reconstruct_chain(&bfs.static_parent, entry, nearest),
389 None => Vec::new(),
390 };
391 HeavyPackage {
392 name,
393 total_size,
394 file_count,
395 chain,
396 }
397 })
398 .collect();
399
400 let exclusive = compute_exclusive_weights(graph, entry, opts.include_dynamic);
402
403 let mut modules_by_cost: Vec<ModuleCost> = reachable
407 .iter()
408 .filter(|&&mid| mid != entry && graph.module(mid).package.is_none())
409 .map(|&mid| ModuleCost {
410 module_id: mid,
411 exclusive_size: exclusive[mid.0 as usize],
412 })
413 .collect();
414 if modules_by_cost.is_empty() {
415 modules_by_cost = reachable
416 .iter()
417 .filter(|&&mid| mid != entry)
418 .map(|&mid| ModuleCost {
419 module_id: mid,
420 exclusive_size: exclusive[mid.0 as usize],
421 })
422 .collect();
423 }
424
425 modules_by_cost.sort_by(|a, b| b.exclusive_size.cmp(&a.exclusive_size));
426
427 TraceResult {
428 static_weight,
429 static_module_count: reachable.len(),
430 dynamic_only_weight,
431 dynamic_only_module_count,
432 heavy_packages,
433 modules_by_cost,
434 all_packages,
435 dynamic_packages: dynamic_pkg_sizes,
436 }
437}
438
439#[must_use]
445pub fn find_all_chains(
446 graph: &ModuleGraph,
447 entry: ModuleId,
448 target: &ChainTarget,
449 include_dynamic: bool,
450) -> Vec<Vec<ModuleId>> {
451 let raw = all_shortest_chains(graph, entry, target, 10, include_dynamic);
452 dedup_chains_by_package(graph, raw)
453}
454
455fn dedup_chains_by_package(
460 graph: &ModuleGraph,
461 chains: Vec<Vec<ModuleId>>,
462) -> Vec<Vec<ModuleId>> {
463 let mut seen: HashSet<Vec<String>> = HashSet::new();
464 let mut result = Vec::new();
465
466 for chain in chains {
467 let key: Vec<String> = chain
468 .iter()
469 .map(|&mid| {
470 let m = graph.module(mid);
471 m.package.clone().unwrap_or_else(|| m.path.to_string_lossy().into_owned())
472 })
473 .collect();
474
475 if seen.insert(key) {
476 result.push(chain);
477 }
478 }
479
480 result
481}
482
483fn all_shortest_chains(
485 graph: &ModuleGraph,
486 entry: ModuleId,
487 target: &ChainTarget,
488 max_chains: usize,
489 include_dynamic: bool,
490) -> Vec<Vec<ModuleId>> {
491 let n = graph.modules.len();
492 let mut parents: Vec<Vec<u32>> = vec![Vec::new(); n];
493 let mut depth: Vec<u32> = vec![u32::MAX; n];
494 let mut queue: VecDeque<ModuleId> = VecDeque::new();
495
496 depth[entry.0 as usize] = 0;
497 queue.push_back(entry);
498
499 let mut target_depth: Option<u32> = None;
500 let mut targets: Vec<ModuleId> = Vec::new();
501
502 while let Some(mid) = queue.pop_front() {
503 let d = depth[mid.0 as usize];
504
505 if let Some(td) = target_depth
507 && d > td
508 {
509 break;
510 }
511
512 if target.matches(graph, mid) {
514 if target_depth.is_none() {
515 target_depth = Some(d);
516 }
517 targets.push(mid);
518 continue; }
520
521 for &edge_id in graph.outgoing_edges(mid) {
522 let edge = graph.edge(edge_id);
523 match edge.kind {
524 EdgeKind::Static => {}
525 EdgeKind::Dynamic if include_dynamic => {}
526 _ => continue,
527 }
528
529 let next_depth = d + 1;
530 let idx = edge.to.0 as usize;
531 match depth[idx] {
532 d if d == next_depth => {
533 parents[idx].push(mid.0);
535 }
536 u32::MAX => {
537 depth[idx] = next_depth;
539 parents[idx].push(mid.0);
540 queue.push_back(edge.to);
541 }
542 _ => {} }
544 }
545 }
546
547 if targets.is_empty() {
548 return Vec::new();
549 }
550
551 let mut all_chains: Vec<Vec<ModuleId>> = Vec::new();
553 for &target_mid in &targets {
554 let mut partial_paths: Vec<Vec<ModuleId>> = vec![vec![target_mid]];
555
556 loop {
557 let mut next_partial: Vec<Vec<ModuleId>> = Vec::new();
558 let mut any_extended = false;
559
560 for path in &partial_paths {
561 let &head = path.last().unwrap();
562 if head == entry {
563 next_partial.push(path.clone());
564 continue;
565 }
566 let pars = &parents[head.0 as usize];
567 if !pars.is_empty() {
568 any_extended = true;
569 for &p in pars {
570 let mut new_path = path.clone();
571 new_path.push(ModuleId(p));
572 next_partial.push(new_path);
573 if next_partial.len() > max_chains * 2 {
574 break; }
576 }
577 }
578 }
579
580 partial_paths = next_partial;
581 if !any_extended || partial_paths.len() > max_chains * 2 {
582 break;
583 }
584 }
585
586 for mut path in partial_paths {
587 path.reverse();
588 if path.first() == Some(&entry) {
589 all_chains.push(path);
590 if all_chains.len() >= max_chains {
591 return all_chains;
592 }
593 }
594 }
595 }
596
597 all_chains
598}
599
600#[derive(Debug, Clone, Copy)]
602#[non_exhaustive]
603pub struct CutModule {
604 pub module_id: ModuleId,
605 pub chains_broken: usize,
606 pub exclusive_size: u64,
607}
608
609#[must_use]
615#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
616pub fn find_cut_modules(
617 graph: &ModuleGraph,
618 chains: &[Vec<ModuleId>],
619 entry: ModuleId,
620 target: &ChainTarget,
621 top_n: i32,
622 include_dynamic: bool,
623) -> Vec<CutModule> {
624 if chains.is_empty() {
625 return Vec::new();
626 }
627
628 let exclusive = compute_exclusive_weights(graph, entry, include_dynamic);
629
630 let total = chains.len();
631 let mut frequency = vec![0usize; graph.modules.len()];
632 for chain in chains {
633 for &mid in chain {
634 frequency[mid.0 as usize] += 1;
635 }
636 }
637
638 let mut cuts: Vec<CutModule> = frequency
639 .iter()
640 .enumerate()
641 .filter(|&(idx, &count)| {
642 let mid = ModuleId(idx as u32);
643 count == total
644 && mid != entry
645 && !target.matches(graph, mid)
646 })
647 .map(|(idx, &count)| CutModule {
648 module_id: ModuleId(idx as u32),
649 chains_broken: count,
650 exclusive_size: exclusive[idx],
651 })
652 .collect();
653
654 cuts.sort_by(|a, b| b.exclusive_size.cmp(&a.exclusive_size));
658 let mut seen_packages: HashSet<String> = HashSet::new();
659 cuts.retain(|c| {
660 let m = graph.module(c.module_id);
661 m.package.as_ref().is_none_or(|pkg| seen_packages.insert(pkg.clone()))
662 });
663
664 if chains.len() == 1 {
667 cuts.sort_by(|a, b| a.exclusive_size.cmp(&b.exclusive_size));
668 }
669
670 if top_n >= 0 {
671 cuts.truncate(top_n as usize);
672 }
673 cuts
674}
675
676#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
678#[non_exhaustive]
679pub struct TraceSnapshot {
680 pub entry: String,
681 pub static_weight: u64,
682 pub packages: HashMap<String, u64>,
683 #[serde(default)]
684 pub dynamic_weight: u64,
685 #[serde(default)]
686 pub dynamic_packages: HashMap<String, u64>,
687}
688
689impl TraceResult {
690 pub fn to_snapshot(&self, entry: &str) -> TraceSnapshot {
691 TraceSnapshot {
692 entry: entry.to_string(),
693 static_weight: self.static_weight,
694 packages: self.all_packages.clone(),
695 dynamic_weight: self.dynamic_only_weight,
696 dynamic_packages: self.dynamic_packages.clone(),
697 }
698 }
699}
700
701#[derive(Debug)]
703#[non_exhaustive]
704pub struct DiffPackage {
705 pub name: String,
706 pub size: u64,
707}
708
709#[derive(Debug)]
711#[non_exhaustive]
712pub struct DiffResult {
713 pub entry_a_weight: u64,
714 pub entry_b_weight: u64,
715 pub weight_delta: i64,
716 pub dynamic_a_weight: u64,
717 pub dynamic_b_weight: u64,
718 pub dynamic_weight_delta: i64,
719 pub shared_count: usize,
720 pub only_in_a: Vec<DiffPackage>,
721 pub only_in_b: Vec<DiffPackage>,
722 pub dynamic_only_in_a: Vec<DiffPackage>,
723 pub dynamic_only_in_b: Vec<DiffPackage>,
724}
725
726#[must_use]
727#[allow(clippy::cast_possible_wrap)]
728pub fn diff_snapshots(a: &TraceSnapshot, b: &TraceSnapshot) -> DiffResult {
729 let keys_a: HashSet<&str> = a.packages.keys().map(String::as_str).collect();
730 let keys_b: HashSet<&str> = b.packages.keys().map(String::as_str).collect();
731
732 let mut only_in_a: Vec<DiffPackage> = keys_a
733 .difference(&keys_b)
734 .map(|&name| DiffPackage {
735 name: name.to_string(),
736 size: a.packages[name],
737 })
738 .collect();
739 only_in_a.sort_by(|x, y| y.size.cmp(&x.size));
740
741 let mut only_in_b: Vec<DiffPackage> = keys_b
742 .difference(&keys_a)
743 .map(|&name| DiffPackage {
744 name: name.to_string(),
745 size: b.packages[name],
746 })
747 .collect();
748 only_in_b.sort_by(|x, y| y.size.cmp(&x.size));
749
750 let dyn_keys_a: HashSet<&str> = a.dynamic_packages.keys().map(String::as_str).collect();
751 let dyn_keys_b: HashSet<&str> = b.dynamic_packages.keys().map(String::as_str).collect();
752
753 let mut dynamic_only_in_a: Vec<DiffPackage> = dyn_keys_a
754 .difference(&dyn_keys_b)
755 .map(|&name| DiffPackage {
756 name: name.to_string(),
757 size: a.dynamic_packages[name],
758 })
759 .collect();
760 dynamic_only_in_a.sort_by(|x, y| y.size.cmp(&x.size));
761
762 let mut dynamic_only_in_b: Vec<DiffPackage> = dyn_keys_b
763 .difference(&dyn_keys_a)
764 .map(|&name| DiffPackage {
765 name: name.to_string(),
766 size: b.dynamic_packages[name],
767 })
768 .collect();
769 dynamic_only_in_b.sort_by(|x, y| y.size.cmp(&x.size));
770
771 DiffResult {
772 entry_a_weight: a.static_weight,
773 entry_b_weight: b.static_weight,
774 weight_delta: b.static_weight as i64 - a.static_weight as i64,
775 dynamic_a_weight: a.dynamic_weight,
776 dynamic_b_weight: b.dynamic_weight,
777 dynamic_weight_delta: b.dynamic_weight as i64 - a.dynamic_weight as i64,
778 shared_count: keys_a.intersection(&keys_b).count(),
779 only_in_a,
780 only_in_b,
781 dynamic_only_in_a,
782 dynamic_only_in_b,
783 }
784}
785
786#[cfg(test)]
787mod tests {
788 use super::*;
789 use crate::graph::ModuleGraph;
790 use std::path::PathBuf;
791
792 fn make_graph(
796 nodes: &[(&str, u64, Option<&str>)],
797 edges: &[(usize, usize, EdgeKind)],
798 ) -> ModuleGraph {
799 let mut graph = ModuleGraph::new();
800 for &(path, size, pkg) in nodes {
801 graph.add_module(
802 PathBuf::from(path),
803 size,
804 pkg.map(str::to_string),
805 );
806 }
807 for &(from, to, kind) in edges {
808 #[allow(clippy::cast_possible_truncation)]
809 graph.add_edge(
810 ModuleId(from as u32),
811 ModuleId(to as u32),
812 kind,
813 "",
814 );
815 }
816 graph
817 }
818
819 #[test]
822 fn trace_static_weight() {
823 let graph = make_graph(
825 &[("a.ts", 100, None), ("b.ts", 200, None), ("c.ts", 300, None)],
826 &[(0, 1, EdgeKind::Static), (1, 2, EdgeKind::Static)],
827 );
828 let result = trace(&graph, ModuleId(0), &TraceOptions::default());
829 assert_eq!(result.static_weight, 600);
830 assert_eq!(result.static_module_count, 3);
831 }
832
833 #[test]
834 fn trace_dynamic_excluded_by_default() {
835 let graph = make_graph(
837 &[("a.ts", 100, None), ("b.ts", 200, None), ("c.ts", 300, None)],
838 &[(0, 1, EdgeKind::Static), (0, 2, EdgeKind::Dynamic)],
839 );
840 let result = trace(&graph, ModuleId(0), &TraceOptions::default());
841 assert_eq!(result.static_weight, 300); assert_eq!(result.dynamic_only_weight, 300); assert_eq!(result.dynamic_only_module_count, 1);
844 }
845
846 #[test]
847 fn trace_include_dynamic() {
848 let graph = make_graph(
850 &[("a.ts", 100, None), ("b.ts", 200, None)],
851 &[(0, 1, EdgeKind::Dynamic)],
852 );
853 let opts = TraceOptions {
854 include_dynamic: true,
855 top_n: 10,
856 ignore: Vec::new(),
857 };
858 let result = trace(&graph, ModuleId(0), &opts);
859 assert!(result.modules_by_cost.iter().any(|m| m.module_id == ModuleId(1)));
861 }
862
863 #[test]
866 fn chain_linear_path() {
867 let graph = make_graph(
869 &[
870 ("a.ts", 100, None),
871 ("b.ts", 100, None),
872 ("c.ts", 100, None),
873 ("node_modules/zod/index.js", 500, Some("zod")),
874 ],
875 &[
876 (0, 1, EdgeKind::Static),
877 (1, 2, EdgeKind::Static),
878 (2, 3, EdgeKind::Static),
879 ],
880 );
881 let chains = find_all_chains(&graph, ModuleId(0), &ChainTarget::Package("zod".to_string()), false);
882 assert_eq!(chains.len(), 1);
883 assert_eq!(chains[0], vec![ModuleId(0), ModuleId(1), ModuleId(2), ModuleId(3)]);
884 }
885
886 #[test]
887 fn chain_dedup_same_package_path() {
888 let graph = make_graph(
893 &[
894 ("a.ts", 100, None),
895 ("b.ts", 100, None),
896 ("node_modules/zod/index.js", 250, Some("zod")),
897 ("node_modules/zod/lib.js", 250, Some("zod")),
898 ],
899 &[
900 (0, 1, EdgeKind::Static),
901 (1, 2, EdgeKind::Static),
902 (1, 3, EdgeKind::Static),
903 ],
904 );
905 let chains = find_all_chains(&graph, ModuleId(0), &ChainTarget::Package("zod".to_string()), false);
906 assert_eq!(chains.len(), 1);
907 }
908
909 #[test]
910 fn chain_not_reachable() {
911 let graph = make_graph(
913 &[
914 ("a.ts", 100, None),
915 ("b.ts", 100, None),
916 ("node_modules/zod/index.js", 500, Some("zod")),
917 ],
918 &[(0, 1, EdgeKind::Static)],
919 );
920 let chains = find_all_chains(&graph, ModuleId(0), &ChainTarget::Package("zod".to_string()), false);
921 assert!(chains.is_empty());
922 }
923
924 #[test]
925 fn chain_through_dynamic_edge() {
926 let graph = make_graph(
929 &[
930 ("a.ts", 100, None),
931 ("b.ts", 100, None),
932 ("node_modules/zod/index.js", 500, Some("zod")),
933 ],
934 &[
935 (0, 1, EdgeKind::Dynamic),
936 (1, 2, EdgeKind::Static),
937 ],
938 );
939 let chains_static = find_all_chains(&graph, ModuleId(0), &ChainTarget::Package("zod".to_string()), false);
940 assert!(chains_static.is_empty());
941
942 let chains_dynamic = find_all_chains(&graph, ModuleId(0), &ChainTarget::Package("zod".to_string()), true);
943 assert_eq!(chains_dynamic.len(), 1);
944 assert_eq!(chains_dynamic[0], vec![ModuleId(0), ModuleId(1), ModuleId(2)]);
945 }
946
947 #[test]
950 fn cut_single_convergence_point() {
951 let graph = make_graph(
955 &[
956 ("a.ts", 100, None),
957 ("b.ts", 100, None),
958 ("c.ts", 100, None),
959 ("d.ts", 100, None),
960 ("node_modules/zod/index.js", 500, Some("zod")),
961 ],
962 &[
963 (0, 1, EdgeKind::Static),
964 (0, 2, EdgeKind::Static),
965 (1, 3, EdgeKind::Static),
966 (2, 3, EdgeKind::Static),
967 (3, 4, EdgeKind::Static),
968 ],
969 );
970 let target = ChainTarget::Package("zod".to_string());
971 let chains = find_all_chains(&graph, ModuleId(0), &target, false);
972 assert_eq!(chains.len(), 2);
973
974 let cuts = find_cut_modules(&graph, &chains, ModuleId(0), &target, 10, false);
975 assert!(!cuts.is_empty());
976 assert!(cuts.iter().any(|c| c.module_id == ModuleId(3)));
977 }
978
979 #[test]
980 fn cut_no_convergence_point() {
981 let graph = make_graph(
986 &[
987 ("a.ts", 100, None),
988 ("b.ts", 100, None),
989 ("c.ts", 100, None),
990 ("node_modules/zod/index.js", 250, Some("zod")),
991 ("node_modules/zod/lib.js", 250, Some("zod")),
992 ],
993 &[
994 (0, 1, EdgeKind::Static),
995 (0, 2, EdgeKind::Static),
996 (1, 3, EdgeKind::Static),
997 (2, 4, EdgeKind::Static),
998 ],
999 );
1000 let target = ChainTarget::Package("zod".to_string());
1001 let chains = find_all_chains(&graph, ModuleId(0), &target, false);
1002 let cuts = find_cut_modules(&graph, &chains, ModuleId(0), &target, 10, false);
1003 assert!(cuts.is_empty());
1004 }
1005
1006 #[test]
1007 fn cut_direct_import_no_intermediate() {
1008 let graph = make_graph(
1011 &[
1012 ("a.ts", 100, None),
1013 ("node_modules/zod/index.js", 500, Some("zod")),
1014 ],
1015 &[(0, 1, EdgeKind::Static)],
1016 );
1017 let target = ChainTarget::Package("zod".to_string());
1018 let chains = find_all_chains(&graph, ModuleId(0), &target, false);
1019 assert_eq!(chains.len(), 1);
1020 assert_eq!(chains[0].len(), 2); let cuts = find_cut_modules(&graph, &chains, ModuleId(0), &target, 10, false);
1023 assert!(cuts.is_empty());
1024 }
1025
1026 #[test]
1027 fn cut_single_chain_ascending_sort() {
1028 let graph = make_graph(
1031 &[
1032 ("a.ts", 100, None),
1033 ("b.ts", 5000, None),
1034 ("c.ts", 100, None),
1035 ("node_modules/zod/index.js", 500, Some("zod")),
1036 ],
1037 &[
1038 (0, 1, EdgeKind::Static),
1039 (1, 2, EdgeKind::Static),
1040 (2, 3, EdgeKind::Static),
1041 ],
1042 );
1043 let target = ChainTarget::Package("zod".to_string());
1044 let chains = find_all_chains(&graph, ModuleId(0), &target, false);
1045 assert_eq!(chains.len(), 1);
1046
1047 let cuts = find_cut_modules(&graph, &chains, ModuleId(0), &target, 10, false);
1048 assert!(cuts.len() >= 2);
1049 assert!(cuts[0].exclusive_size <= cuts[1].exclusive_size);
1051 }
1052
1053 fn snap(entry: &str, static_weight: u64, packages: &[(&str, u64)]) -> TraceSnapshot {
1056 TraceSnapshot {
1057 entry: entry.to_string(),
1058 static_weight,
1059 packages: packages.iter().map(|(k, v)| (k.to_string(), *v)).collect(),
1060 dynamic_weight: 0,
1061 dynamic_packages: HashMap::new(),
1062 }
1063 }
1064
1065 fn snap_with_dynamic(
1066 entry: &str,
1067 static_weight: u64,
1068 packages: &[(&str, u64)],
1069 dynamic_weight: u64,
1070 dynamic_packages: &[(&str, u64)],
1071 ) -> TraceSnapshot {
1072 TraceSnapshot {
1073 entry: entry.to_string(),
1074 static_weight,
1075 packages: packages.iter().map(|(k, v)| (k.to_string(), *v)).collect(),
1076 dynamic_weight,
1077 dynamic_packages: dynamic_packages.iter().map(|(k, v)| (k.to_string(), *v)).collect(),
1078 }
1079 }
1080
1081 #[test]
1082 fn diff_snapshots_computes_sets() {
1083 let a = snap("a.ts", 1000, &[("zod", 500), ("chalk", 200), ("tslog", 300)]);
1084 let b = snap("b.ts", 800, &[("chalk", 200), ("tslog", 300), ("ajv", 100)]);
1085 let diff = diff_snapshots(&a, &b);
1086
1087 assert_eq!(diff.entry_a_weight, 1000);
1088 assert_eq!(diff.entry_b_weight, 800);
1089 assert_eq!(diff.weight_delta, -200);
1090 assert_eq!(diff.only_in_a.len(), 1);
1091 assert_eq!(diff.only_in_a[0].name, "zod");
1092 assert_eq!(diff.only_in_a[0].size, 500);
1093 assert_eq!(diff.only_in_b.len(), 1);
1094 assert_eq!(diff.only_in_b[0].name, "ajv");
1095 assert_eq!(diff.only_in_b[0].size, 100);
1096 assert_eq!(diff.shared_count, 2);
1097 }
1098
1099 #[test]
1100 fn diff_snapshots_sorted_by_size_descending() {
1101 let a = snap("a.ts", 1000, &[("small", 10), ("big", 500), ("medium", 100)]);
1102 let b = snap("b.ts", 0, &[]);
1103 let diff = diff_snapshots(&a, &b);
1104
1105 assert_eq!(diff.only_in_a.len(), 3);
1106 assert_eq!(diff.only_in_a[0].name, "big");
1107 assert_eq!(diff.only_in_a[1].name, "medium");
1108 assert_eq!(diff.only_in_a[2].name, "small");
1109 }
1110
1111 #[test]
1112 fn diff_snapshots_dynamic_packages() {
1113 let a = snap_with_dynamic("a.ts", 1000, &[("zod", 500)], 200, &[("lodash", 200)]);
1114 let b = snap_with_dynamic("b.ts", 800, &[("zod", 500)], 350, &[("moment", 350)]);
1115 let diff = diff_snapshots(&a, &b);
1116
1117 assert_eq!(diff.dynamic_a_weight, 200);
1118 assert_eq!(diff.dynamic_b_weight, 350);
1119 assert_eq!(diff.dynamic_weight_delta, 150);
1120 assert_eq!(diff.dynamic_only_in_a.len(), 1);
1121 assert_eq!(diff.dynamic_only_in_a[0].name, "lodash");
1122 assert_eq!(diff.dynamic_only_in_b.len(), 1);
1123 assert_eq!(diff.dynamic_only_in_b[0].name, "moment");
1124 }
1125
1126 #[test]
1129 fn exclusive_weight_linear_chain() {
1130 let graph = make_graph(
1133 &[("entry.ts", 100, None), ("a.ts", 200, None), ("b.ts", 300, None)],
1134 &[(0, 1, EdgeKind::Static), (1, 2, EdgeKind::Static)],
1135 );
1136 let weights = compute_exclusive_weights(&graph, ModuleId(0), false);
1137 assert_eq!(weights[0], 600); assert_eq!(weights[1], 500); assert_eq!(weights[2], 300); }
1141
1142 #[test]
1143 fn exclusive_weight_diamond_shared() {
1144 let graph = make_graph(
1148 &[
1149 ("entry.ts", 100, None), ("a.ts", 200, None),
1150 ("b.ts", 300, None), ("d.ts", 500, None),
1151 ],
1152 &[
1153 (0, 1, EdgeKind::Static), (0, 2, EdgeKind::Static),
1154 (1, 3, EdgeKind::Static), (2, 3, EdgeKind::Static),
1155 ],
1156 );
1157 let weights = compute_exclusive_weights(&graph, ModuleId(0), false);
1158 assert_eq!(weights[0], 1100); assert_eq!(weights[1], 200); assert_eq!(weights[2], 300); assert_eq!(weights[3], 500); }
1163
1164 #[test]
1165 fn exclusive_weight_mixed_exclusive_and_shared() {
1166 let graph = make_graph(
1170 &[
1171 ("entry.ts", 100, None), ("a.ts", 200, None),
1172 ("b.ts", 300, None), ("d.ts", 500, None),
1173 ("e.ts", 600, None),
1174 ],
1175 &[
1176 (0, 1, EdgeKind::Static), (0, 2, EdgeKind::Static),
1177 (1, 3, EdgeKind::Static), (2, 3, EdgeKind::Static),
1178 (1, 4, EdgeKind::Static),
1179 ],
1180 );
1181 let weights = compute_exclusive_weights(&graph, ModuleId(0), false);
1182 assert_eq!(weights[0], 1700); assert_eq!(weights[1], 800); assert_eq!(weights[2], 300); assert_eq!(weights[3], 500); assert_eq!(weights[4], 600); }
1188
1189 #[test]
1190 fn exclusive_weight_with_dynamic_edges() {
1191 let graph = make_graph(
1196 &[
1197 ("entry.ts", 100, None), ("a.ts", 200, None),
1198 ("b.ts", 300, None), ("c.ts", 400, None),
1199 ],
1200 &[
1201 (0, 1, EdgeKind::Static), (0, 2, EdgeKind::Dynamic),
1202 (1, 3, EdgeKind::Static), (2, 3, EdgeKind::Static),
1203 ],
1204 );
1205 let static_weights = compute_exclusive_weights(&graph, ModuleId(0), false);
1207 assert_eq!(static_weights[1], 600); let all_weights = compute_exclusive_weights(&graph, ModuleId(0), true);
1211 assert_eq!(all_weights[1], 200); assert_eq!(all_weights[2], 300); }
1214
1215 #[test]
1218 fn trace_ignore_filters_heavy_packages() {
1219 let graph = make_graph(
1220 &[
1221 ("entry.ts", 100, None),
1222 ("a.ts", 50, Some("pkg-a")),
1223 ("b.ts", 200, Some("pkg-b")),
1224 ("c.ts", 300, Some("pkg-c")),
1225 ],
1226 &[
1227 (0, 1, EdgeKind::Static),
1228 (0, 2, EdgeKind::Static),
1229 (0, 3, EdgeKind::Static),
1230 ],
1231 );
1232 let opts = TraceOptions {
1233 include_dynamic: false,
1234 top_n: 10,
1235 ignore: vec!["pkg-c".to_string()],
1236 };
1237 let result = trace(&graph, ModuleId(0), &opts);
1238 let names: Vec<&str> = result.heavy_packages.iter().map(|p| p.name.as_str()).collect();
1239 assert!(names.contains(&"pkg-a"));
1240 assert!(names.contains(&"pkg-b"));
1241 assert!(!names.contains(&"pkg-c"));
1242 }
1243
1244 #[test]
1245 fn trace_ignore_does_not_affect_total_weight() {
1246 let graph = make_graph(
1247 &[
1248 ("entry.ts", 100, None),
1249 ("a.ts", 500, Some("big-pkg")),
1250 ],
1251 &[
1252 (0, 1, EdgeKind::Static),
1253 ],
1254 );
1255 let opts = TraceOptions {
1256 include_dynamic: false,
1257 top_n: 10,
1258 ignore: vec!["big-pkg".to_string()],
1259 };
1260 let result = trace(&graph, ModuleId(0), &opts);
1261 assert!(result.heavy_packages.is_empty());
1262 assert_eq!(result.static_weight, 600);
1263 }
1264
1265 #[test]
1268 fn chain_to_module_by_id() {
1269 let graph = make_graph(
1270 &[
1271 ("entry.ts", 100, None),
1272 ("a.ts", 50, None),
1273 ("b.ts", 200, None),
1274 ],
1275 &[
1276 (0, 1, EdgeKind::Static),
1277 (1, 2, EdgeKind::Static),
1278 ],
1279 );
1280 let target_id = graph.path_to_id[&PathBuf::from("b.ts")];
1281 let chains = find_all_chains(&graph, ModuleId(0), &ChainTarget::Module(target_id), false);
1282 assert_eq!(chains.len(), 1);
1283 assert_eq!(chains[0].len(), 3);
1284 assert_eq!(*chains[0].last().unwrap(), target_id);
1285 }
1286
1287 #[test]
1288 fn cut_to_module_by_id() {
1289 let graph = make_graph(
1290 &[
1291 ("entry.ts", 100, None),
1292 ("bridge.ts", 50, None),
1293 ("target.ts", 200, None),
1294 ],
1295 &[
1296 (0, 1, EdgeKind::Static),
1297 (1, 2, EdgeKind::Static),
1298 ],
1299 );
1300 let target_id = graph.path_to_id[&PathBuf::from("target.ts")];
1301 let target = ChainTarget::Module(target_id);
1302 let chains = find_all_chains(&graph, ModuleId(0), &target, false);
1303 let cuts = find_cut_modules(&graph, &chains, ModuleId(0), &target, 10, false);
1304 assert_eq!(cuts.len(), 1);
1305 assert_eq!(cuts[0].module_id, graph.path_to_id[&PathBuf::from("bridge.ts")]);
1306 }
1307
1308 #[test]
1311 fn trace_top_n_negative_shows_all() {
1312 let graph = make_graph(
1313 &[
1314 ("entry.ts", 10, None),
1315 ("a.ts", 10, Some("pkg-a")),
1316 ("b.ts", 10, Some("pkg-b")),
1317 ("c.ts", 10, Some("pkg-c")),
1318 ],
1319 &[
1320 (0, 1, EdgeKind::Static),
1321 (0, 2, EdgeKind::Static),
1322 (0, 3, EdgeKind::Static),
1323 ],
1324 );
1325 let opts = TraceOptions { top_n: -1, ..Default::default() };
1326 let result = trace(&graph, ModuleId(0), &opts);
1327 assert_eq!(result.heavy_packages.len(), 3);
1328 }
1329
1330 #[test]
1331 fn trace_top_n_zero_shows_none() {
1332 let graph = make_graph(
1333 &[
1334 ("entry.ts", 10, None),
1335 ("a.ts", 10, Some("pkg-a")),
1336 ],
1337 &[
1338 (0, 1, EdgeKind::Static),
1339 ],
1340 );
1341 let opts = TraceOptions { top_n: 0, ..Default::default() };
1342 let result = trace(&graph, ModuleId(0), &opts);
1343 assert_eq!(result.heavy_packages.len(), 0);
1344 }
1345}