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(
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 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(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 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 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 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
306fn 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 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 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 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 let exclusive = compute_exclusive_weights(graph, entry, opts.include_dynamic);
406
407 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#[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
459fn 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
486fn 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 let Some(td) = target_depth
510 && d > td
511 {
512 break;
513 }
514
515 if target.matches(graph, mid) {
517 if target_depth.is_none() {
518 target_depth = Some(d);
519 }
520 targets.push(mid);
521 continue; }
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 parents[idx].push(mid.0);
538 }
539 u32::MAX => {
540 depth[idx] = next_depth;
542 parents[idx].push(mid.0);
543 queue.push_back(edge.to);
544 }
545 _ => {} }
547 }
548 }
549
550 if targets.is_empty() {
551 return Vec::new();
552 }
553
554 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; }
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#[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#[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 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 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#[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#[derive(Debug)]
706#[non_exhaustive]
707pub struct DiffPackage {
708 pub name: String,
709 pub size: u64,
710}
711
712#[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 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 #[test]
816 fn trace_static_weight() {
817 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 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); assert_eq!(result.dynamic_only_weight, 300); assert_eq!(result.dynamic_only_module_count, 1);
846 }
847
848 #[test]
849 fn trace_include_dynamic() {
850 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 assert!(
863 result
864 .modules_by_cost
865 .iter()
866 .any(|m| m.module_id == ModuleId(1))
867 );
868 }
869
870 #[test]
873 fn chain_linear_path() {
874 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 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 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 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 #[test]
985 fn cut_single_convergence_point() {
986 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 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 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); 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 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 assert!(cuts[0].exclusive_size <= cuts[1].exclusive_size);
1086 }
1087
1088 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 #[test]
1181 fn exclusive_weight_linear_chain() {
1182 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); assert_eq!(weights[1], 500); assert_eq!(weights[2], 300); }
1197
1198 #[test]
1199 fn exclusive_weight_diamond_shared() {
1200 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); assert_eq!(weights[1], 200); assert_eq!(weights[2], 300); assert_eq!(weights[3], 500); }
1223
1224 #[test]
1225 fn exclusive_weight_mixed_exclusive_and_shared() {
1226 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); assert_eq!(weights[1], 800); assert_eq!(weights[2], 300); assert_eq!(weights[3], 500); assert_eq!(weights[4], 600); }
1252
1253 #[test]
1254 fn exclusive_weight_with_dynamic_edges() {
1255 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 let static_weights = compute_exclusive_weights(&graph, ModuleId(0), false);
1275 assert_eq!(static_weights[1], 600); let all_weights = compute_exclusive_weights(&graph, ModuleId(0), true);
1279 assert_eq!(all_weights[1], 200); assert_eq!(all_weights[2], 300); }
1282
1283 #[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 #[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 #[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}