fob_graph/
dependency_chain.rs

1//! Dependency chain analysis for understanding module relationships.
2//!
3//! This module provides tools to trace how modules are connected through
4//! import chains, useful for debugging why a module is included in a bundle
5//! or understanding circular dependencies.
6
7use serde::{Deserialize, Serialize};
8use std::collections::VecDeque;
9
10use super::ModuleId;
11
12/// Maximum depth for dependency chain traversal to prevent DoS attacks and infinite loops.
13///
14/// This limit prevents path explosion in large graphs with deep dependency chains.
15/// A value of 50 allows for very deep dependency graphs while still preventing
16/// exponential growth that could cause performance issues or memory exhaustion.
17const MAX_DEPENDENCY_CHAIN_DEPTH: usize = 50;
18
19/// A chain of dependencies from an entry point to a target module.
20///
21/// Represents one path through the dependency graph, useful for understanding
22/// why a particular module is included in the bundle.
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub struct DependencyChain {
25    /// The path of module IDs from entry to target
26    pub path: Vec<ModuleId>,
27    /// Depth of this chain (path length - 1)
28    pub depth: usize,
29}
30
31impl DependencyChain {
32    /// Create a new dependency chain from a path.
33    pub fn new(path: Vec<ModuleId>) -> Self {
34        let depth = if !path.is_empty() { path.len() - 1 } else { 0 };
35
36        Self { path, depth }
37    }
38
39    /// Get the entry point (first module in the chain).
40    pub fn entry_point(&self) -> Option<&ModuleId> {
41        self.path.first()
42    }
43
44    /// Get the target (last module in the chain).
45    pub fn target(&self) -> Option<&ModuleId> {
46        self.path.last()
47    }
48
49    /// Check if this chain contains a cycle (same module appears twice).
50    pub fn has_cycle(&self) -> bool {
51        use std::collections::HashSet;
52        let mut seen = HashSet::new();
53        for module in &self.path {
54            if !seen.insert(module) {
55                return true;
56            }
57        }
58        false
59    }
60
61    /// Format the chain as a human-readable string.
62    ///
63    /// Example: "entry.js -> utils.js -> helper.js"
64    pub fn format_chain(&self) -> String {
65        self.path
66            .iter()
67            .map(|id| id.path_string())
68            .collect::<Vec<_>>()
69            .join(" -> ")
70    }
71}
72
73/// Analysis of all dependency chains to a module.
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct ChainAnalysis {
76    /// Target module being analyzed
77    pub target: ModuleId,
78    /// All chains leading to this module
79    pub chains: Vec<DependencyChain>,
80    /// Shortest chain depth
81    pub min_depth: Option<usize>,
82    /// Longest chain depth
83    pub max_depth: Option<usize>,
84    /// Average chain depth
85    pub avg_depth: f64,
86    /// Number of unique entry points that reach this module
87    pub entry_point_count: usize,
88}
89
90impl ChainAnalysis {
91    /// Create chain analysis from a collection of chains.
92    pub fn from_chains(target: ModuleId, chains: Vec<DependencyChain>) -> Self {
93        let min_depth = chains.iter().map(|c| c.depth).min();
94        let max_depth = chains.iter().map(|c| c.depth).max();
95        let avg_depth = if chains.is_empty() {
96            0.0
97        } else {
98            chains.iter().map(|c| c.depth).sum::<usize>() as f64 / chains.len() as f64
99        };
100
101        let mut entry_points = std::collections::HashSet::new();
102        for chain in &chains {
103            if let Some(entry) = chain.entry_point() {
104                entry_points.insert(entry.clone());
105            }
106        }
107
108        Self {
109            target,
110            chains,
111            min_depth,
112            max_depth,
113            avg_depth,
114            entry_point_count: entry_points.len(),
115        }
116    }
117
118    /// Check if the module is reachable from any entry point.
119    pub fn is_reachable(&self) -> bool {
120        !self.chains.is_empty()
121    }
122
123    /// Get the shortest chain (if any).
124    pub fn shortest_chain(&self) -> Option<&DependencyChain> {
125        self.chains.iter().min_by_key(|c| c.depth)
126    }
127
128    /// Get all chains containing cycles.
129    pub fn circular_chains(&self) -> Vec<&DependencyChain> {
130        self.chains.iter().filter(|c| c.has_cycle()).collect()
131    }
132}
133
134/// Find all dependency chains from entry points to a target module using BFS.
135///
136/// This is an internal helper used by ModuleGraph implementations.
137pub(crate) fn find_chains<F>(
138    entry_points: &[ModuleId],
139    target: &ModuleId,
140    mut get_dependencies: F,
141) -> Vec<DependencyChain>
142where
143    F: FnMut(&ModuleId) -> Vec<ModuleId>,
144{
145    let mut chains = Vec::new();
146    let mut queue: VecDeque<Vec<ModuleId>> = VecDeque::new();
147
148    // Start BFS from each entry point
149    for entry in entry_points {
150        queue.push_back(vec![entry.clone()]);
151    }
152
153    // Track visited paths to avoid infinite loops in circular dependencies
154    let mut visited_paths = std::collections::HashSet::new();
155
156    while let Some(current_path) = queue.pop_front() {
157        // Defensive check: skip empty paths (should never happen, but prevents panic)
158        let current_module = match current_path.last() {
159            Some(module) => module,
160            None => continue, // Skip empty paths - this indicates a bug but we handle gracefully
161        };
162
163        // Create a path signature for cycle detection
164        let path_sig = current_path
165            .iter()
166            .map(|id| id.path_string())
167            .collect::<Vec<_>>()
168            .join("->");
169
170        if !visited_paths.insert(path_sig) {
171            // We've seen this exact path before, skip to avoid infinite loops
172            continue;
173        }
174
175        // If we've reached the target, record the chain
176        // Continue exploring to find cycles (paths that include target multiple times)
177        if current_module == target {
178            chains.push(DependencyChain::new(current_path.clone()));
179            // Don't return here - continue exploring to find cycles
180        }
181
182        // Limit path depth to prevent explosion in large graphs (DoS protection)
183        if current_path.len() > MAX_DEPENDENCY_CHAIN_DEPTH {
184            continue;
185        }
186
187        // Explore dependencies
188        for dep in get_dependencies(current_module) {
189            let mut new_path = current_path.clone();
190            new_path.push(dep);
191            queue.push_back(new_path);
192        }
193    }
194
195    chains
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    fn mock_module_id(name: &str) -> ModuleId {
203        ModuleId::new_virtual(format!("virtual:{}", name))
204    }
205
206    #[test]
207    fn test_empty_dependency_path_edge_case() {
208        // Test that find_chains handles empty paths gracefully
209        // This should never happen in practice, but we handle it defensively
210        let entry = mock_module_id("entry");
211        let target = mock_module_id("target");
212
213        // Create a get_dependencies function that returns empty vec
214        let get_dependencies = |_id: &ModuleId| -> Vec<ModuleId> { vec![] };
215
216        // Should not panic even with edge cases
217        let chains = find_chains(&[entry], &target, get_dependencies);
218
219        // With no dependencies, we should only find the entry point if it matches target
220        // Since entry != target, chains should be empty
221        assert_eq!(chains.len(), 0);
222    }
223
224    #[test]
225    fn test_dependency_chain_creation() {
226        let path = vec![
227            mock_module_id("entry"),
228            mock_module_id("utils"),
229            mock_module_id("target"),
230        ];
231
232        let chain = DependencyChain::new(path.clone());
233
234        assert_eq!(chain.depth, 2);
235        assert_eq!(chain.entry_point(), Some(&path[0]));
236        assert_eq!(chain.target(), Some(&path[2]));
237        assert!(!chain.has_cycle());
238    }
239
240    #[test]
241    fn test_dependency_chain_cycle_detection() {
242        let a = mock_module_id("a");
243        let b = mock_module_id("b");
244
245        // Chain with cycle: a -> b -> a
246        let path = vec![a.clone(), b.clone(), a.clone()];
247        let chain = DependencyChain::new(path);
248
249        assert!(chain.has_cycle());
250    }
251
252    #[test]
253    fn test_dependency_chain_no_cycle() {
254        let path = vec![
255            mock_module_id("a"),
256            mock_module_id("b"),
257            mock_module_id("c"),
258        ];
259        let chain = DependencyChain::new(path);
260
261        assert!(!chain.has_cycle());
262    }
263
264    #[test]
265    fn test_chain_analysis() {
266        let target = mock_module_id("target");
267
268        let chains = vec![
269            DependencyChain::new(vec![mock_module_id("entry1"), target.clone()]),
270            DependencyChain::new(vec![
271                mock_module_id("entry2"),
272                mock_module_id("middle"),
273                target.clone(),
274            ]),
275        ];
276
277        let analysis = ChainAnalysis::from_chains(target.clone(), chains);
278
279        assert!(analysis.is_reachable());
280        assert_eq!(analysis.min_depth, Some(1));
281        assert_eq!(analysis.max_depth, Some(2));
282        assert_eq!(analysis.avg_depth, 1.5);
283        assert_eq!(analysis.entry_point_count, 2);
284    }
285
286    #[test]
287    fn test_chain_analysis_unreachable() {
288        let target = mock_module_id("unreachable");
289        let analysis = ChainAnalysis::from_chains(target, vec![]);
290
291        assert!(!analysis.is_reachable());
292        assert_eq!(analysis.min_depth, None);
293        assert_eq!(analysis.max_depth, None);
294        assert_eq!(analysis.avg_depth, 0.0);
295        assert_eq!(analysis.entry_point_count, 0);
296    }
297
298    #[test]
299    fn test_find_chains_basic() {
300        // Graph: entry -> a -> target
301        let entry = mock_module_id("entry");
302        let a = mock_module_id("a");
303        let target = mock_module_id("target");
304
305        let get_deps = |module: &ModuleId| -> Vec<ModuleId> {
306            if module == &entry {
307                vec![a.clone()]
308            } else if module == &a {
309                vec![target.clone()]
310            } else {
311                vec![]
312            }
313        };
314
315        let chains = find_chains(std::slice::from_ref(&entry), &target, get_deps);
316
317        assert_eq!(chains.len(), 1);
318        assert_eq!(chains[0].path.len(), 3);
319        assert_eq!(chains[0].depth, 2);
320    }
321
322    #[test]
323    fn test_find_chains_multiple_paths() {
324        // Graph:
325        //   entry -> a -> target
326        //   entry -> b -> target
327        let entry = mock_module_id("entry");
328        let a = mock_module_id("a");
329        let b = mock_module_id("b");
330        let target = mock_module_id("target");
331
332        let get_deps = |module: &ModuleId| -> Vec<ModuleId> {
333            if module == &entry {
334                vec![a.clone(), b.clone()]
335            } else if module == &a || module == &b {
336                vec![target.clone()]
337            } else {
338                vec![]
339            }
340        };
341
342        let chains = find_chains(std::slice::from_ref(&entry), &target, get_deps);
343
344        assert_eq!(chains.len(), 2);
345        assert!(chains.iter().all(|c| c.path.len() == 3));
346    }
347
348    #[test]
349    fn test_format_chain() {
350        let path = vec![
351            mock_module_id("entry"),
352            mock_module_id("utils"),
353            mock_module_id("target"),
354        ];
355
356        let chain = DependencyChain::new(path);
357        let formatted = chain.format_chain();
358
359        assert!(formatted.contains("entry"));
360        assert!(formatted.contains("utils"));
361        assert!(formatted.contains("target"));
362        assert!(formatted.contains("->"));
363    }
364}