1use crate::analysis::flow::detect_entry_points;
2use crate::model::{
3 DeadCodeAnalysis, DeadCodeConfig, DeadCodeSummary, DeadSymbol, Edge, EdgeKind, EntryPointKind,
4 FlowConfig, SymbolKind, SymbolNode,
5};
6use globset::{Glob, GlobSet, GlobSetBuilder};
7use std::collections::{HashMap, HashSet};
8
9const USAGE_EDGES: &[EdgeKind] = &[
12 EdgeKind::Calls,
13 EdgeKind::Extends,
14 EdgeKind::Implements,
15 EdgeKind::Embeds,
16 EdgeKind::ImportsFrom,
17 EdgeKind::ReExport,
18 EdgeKind::BarrelReExportAll,
19 EdgeKind::TypeReference,
20 EdgeKind::DotImport,
21 EdgeKind::DependsOn,
22 EdgeKind::ConditionalImport,
23 EdgeKind::SideEffectImport,
24];
25
26fn build_glob_set(patterns: &[String]) -> Option<GlobSet> {
28 if patterns.is_empty() {
29 return None;
30 }
31 let mut builder = GlobSetBuilder::new();
32 for pattern in patterns {
33 if let Ok(glob) = Glob::new(pattern) {
34 builder.add(glob);
35 }
36 }
37 builder.build().ok()
38}
39
40pub fn detect_dead_code(
45 symbols: &[SymbolNode],
46 edges: &[Edge],
47 config: &DeadCodeConfig,
48) -> DeadCodeAnalysis {
49 let usage_set: HashSet<&EdgeKind> = USAGE_EDGES.iter().collect();
51 let alive: HashSet<&str> = edges
52 .iter()
53 .filter(|e| usage_set.contains(&e.kind))
54 .map(|e| e.target.as_str())
55 .collect();
56
57 let entry_points = detect_entry_points(symbols, edges, &FlowConfig::default());
59 let mut entry_point_names: HashSet<&str> = entry_points
60 .iter()
61 .filter(|ep| ep.kind != EntryPointKind::Test)
62 .map(|ep| ep.qualified_name.as_str())
63 .collect();
64
65 let ep_glob = build_glob_set(&config.entry_point_patterns);
67 if let Some(ref gs) = ep_glob {
68 for sym in symbols {
69 if gs.is_match(&sym.qualified_name) {
70 entry_point_names.insert(&sym.qualified_name);
71 }
72 }
73 }
74
75 let migration_glob = build_glob_set(&config.migration_patterns);
77
78 let user_glob = build_glob_set(&config.exclude_patterns);
80
81 let mut dead_symbols = Vec::new();
83 let mut excluded_count = 0usize;
84 let total_symbols = symbols.len();
85
86 for sym in symbols {
87 if alive.contains(sym.qualified_name.as_str()) {
89 continue;
90 }
91
92 if entry_point_names.contains(sym.qualified_name.as_str()) {
94 excluded_count += 1;
95 continue;
96 }
97
98 if sym.is_exported {
100 excluded_count += 1;
101 continue;
102 }
103
104 if sym.is_test && !config.include_tests {
106 excluded_count += 1;
107 continue;
108 }
109
110 if let Some(ref gs) = migration_glob {
112 let file_str = sym.location.file.to_string_lossy();
113 if gs.is_match(file_str.as_ref()) {
114 excluded_count += 1;
115 continue;
116 }
117 }
118
119 if let Some(ref gs) = user_glob {
121 let file_str = sym.location.file.to_string_lossy();
122 if gs.is_match(&sym.qualified_name) || gs.is_match(file_str.as_ref()) {
123 excluded_count += 1;
124 continue;
125 }
126 }
127
128 dead_symbols.push(DeadSymbol {
130 qualified_name: sym.qualified_name.clone(),
131 kind: sym.kind,
132 file_path: sym.location.file.to_string_lossy().to_string(),
133 line: sym.location.line_start,
134 visibility: sym.visibility,
135 });
136 }
137
138 if let Some(ref kinds) = config.kind_filter {
140 let kind_set: HashSet<&SymbolKind> = kinds.iter().collect();
141 dead_symbols.retain(|s| kind_set.contains(&s.kind));
142 }
143
144 let dead_count = dead_symbols.len();
146 let dead_percentage = if total_symbols > 0 {
147 dead_count as f64 / total_symbols as f64 * 100.0
148 } else {
149 0.0
150 };
151
152 let mut dead_by_kind: HashMap<SymbolKind, usize> = HashMap::new();
153 let mut dead_by_file_map: HashMap<String, usize> = HashMap::new();
154 for ds in &dead_symbols {
155 *dead_by_kind.entry(ds.kind).or_default() += 1;
156 *dead_by_file_map.entry(ds.file_path.clone()).or_default() += 1;
157 }
158 let mut dead_by_file: Vec<(String, usize)> = dead_by_file_map.into_iter().collect();
159 dead_by_file.sort_by(|a, b| b.1.cmp(&a.1));
160
161 DeadCodeAnalysis {
162 dead_symbols,
163 summary: DeadCodeSummary {
164 total_symbols,
165 dead_count,
166 dead_percentage,
167 excluded_count,
168 dead_by_kind,
169 dead_by_file,
170 },
171 }
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177 use crate::model::{Edge, EdgeKind, Location, SymbolKind, SymbolNode, Visibility};
178
179 fn make_symbol(name: &str, file: &str) -> SymbolNode {
180 SymbolNode {
181 name: name.split("::").last().unwrap_or(name).into(),
182 qualified_name: name.into(),
183 kind: SymbolKind::Function,
184 location: Location {
185 file: file.into(),
186 line_start: 1,
187 line_end: 10,
188 col_start: 0,
189 col_end: 0,
190 },
191 visibility: Visibility::Public,
192 is_exported: false,
193 is_async: false,
194 is_test: false,
195 decorators: vec![],
196 signature: None,
197 }
198 }
199
200 fn make_edge(source: &str, target: &str, kind: EdgeKind) -> Edge {
201 Edge {
202 kind,
203 source: source.into(),
204 target: target.into(),
205 metadata: None,
206 }
207 }
208
209 #[test]
210 fn unused_symbol_detected() {
211 let symbols = vec![make_symbol("src/lib.rs::unused_fn", "src/lib.rs")];
212 let edges: Vec<Edge> = vec![];
213 let result = detect_dead_code(&symbols, &edges, &DeadCodeConfig::default());
214 assert_eq!(result.dead_symbols.len(), 1);
215 assert_eq!(
216 result.dead_symbols[0].qualified_name,
217 "src/lib.rs::unused_fn"
218 );
219 }
220
221 #[test]
222 fn used_symbol_alive() {
223 let symbols = vec![make_symbol("src/lib.rs::used_fn", "src/lib.rs")];
224 let edges = vec![make_edge(
225 "src/main.rs::main",
226 "src/lib.rs::used_fn",
227 EdgeKind::Calls,
228 )];
229 let result = detect_dead_code(&symbols, &edges, &DeadCodeConfig::default());
230 assert_eq!(result.dead_symbols.len(), 0);
231 }
232
233 #[test]
234 fn structural_edges_do_not_count_as_usage() {
235 let symbols = vec![make_symbol("src/lib.rs::inner_fn", "src/lib.rs")];
236 let edges = vec![make_edge(
237 "src/lib.rs::Module",
238 "src/lib.rs::inner_fn",
239 EdgeKind::Contains,
240 )];
241 let result = detect_dead_code(&symbols, &edges, &DeadCodeConfig::default());
242 assert_eq!(
243 result.dead_symbols.len(),
244 1,
245 "Contains edge should not make symbol alive"
246 );
247 }
248
249 #[test]
250 fn tested_by_does_not_count_as_usage() {
251 let symbols = vec![make_symbol("src/lib.rs::fn_only_tested", "src/lib.rs")];
252 let edges = vec![make_edge(
253 "tests/test.rs::test_fn",
254 "src/lib.rs::fn_only_tested",
255 EdgeKind::TestedBy,
256 )];
257 let result = detect_dead_code(&symbols, &edges, &DeadCodeConfig::default());
258 assert_eq!(
259 result.dead_symbols.len(),
260 1,
261 "TestedBy should not make symbol alive"
262 );
263 }
264
265 #[test]
266 fn exported_symbol_excluded() {
267 let mut sym = make_symbol("src/lib.rs::public_api", "src/lib.rs");
268 sym.is_exported = true;
269 let result = detect_dead_code(&[sym], &[], &DeadCodeConfig::default());
270 assert_eq!(result.dead_symbols.len(), 0);
271 assert_eq!(result.summary.excluded_count, 1);
272 }
273
274 #[test]
275 fn test_function_excluded_by_default() {
276 let mut sym = make_symbol("src/lib.rs::test_helper", "src/lib.rs");
277 sym.is_test = true;
278 let result = detect_dead_code(&[sym], &[], &DeadCodeConfig::default());
279 assert_eq!(result.dead_symbols.len(), 0);
280 assert_eq!(result.summary.excluded_count, 1);
281 }
282
283 #[test]
284 fn include_tests_flags_dead_tests() {
285 let mut sym = make_symbol("src/lib.rs::test_helper", "src/lib.rs");
286 sym.is_test = true;
287 let config = DeadCodeConfig {
288 include_tests: true,
289 ..DeadCodeConfig::default()
290 };
291 let result = detect_dead_code(&[sym], &[], &config);
292 assert_eq!(
293 result.dead_symbols.len(),
294 1,
295 "test fn should be flagged when include_tests=true"
296 );
297 }
298
299 #[test]
300 fn migration_file_excluded() {
301 let sym = make_symbol("migrations/001.rs::up", "migrations/001.rs");
302 let result = detect_dead_code(&[sym], &[], &DeadCodeConfig::default());
303 assert_eq!(result.dead_symbols.len(), 0);
304 assert_eq!(result.summary.excluded_count, 1);
305 }
306
307 #[test]
308 fn user_pattern_excludes_by_qualified_name() {
309 let sym = make_symbol(
310 "src/generated/types.rs::AutoStruct",
311 "src/generated/types.rs",
312 );
313 let config = DeadCodeConfig {
314 exclude_patterns: vec!["**/generated/**".into()],
315 ..DeadCodeConfig::default()
316 };
317 let result = detect_dead_code(&[sym], &[], &config);
318 assert_eq!(result.dead_symbols.len(), 0);
319 assert_eq!(result.summary.excluded_count, 1);
320 }
321
322 #[test]
323 fn kind_filter_restricts_results() {
324 let mut sym_fn = make_symbol("src/lib.rs::dead_fn", "src/lib.rs");
325 sym_fn.kind = SymbolKind::Function;
326 let mut sym_struct = make_symbol("src/lib.rs::DeadStruct", "src/lib.rs");
327 sym_struct.kind = SymbolKind::Struct;
328 let config = DeadCodeConfig {
329 kind_filter: Some(vec![SymbolKind::Function]),
330 ..DeadCodeConfig::default()
331 };
332 let result = detect_dead_code(&[sym_fn, sym_struct], &[], &config);
333 assert_eq!(result.dead_symbols.len(), 1);
334 assert_eq!(result.dead_symbols[0].kind, SymbolKind::Function);
335 }
336
337 #[test]
338 fn entry_point_test_kind_not_excluded_as_entry_point() {
339 let mut sym = make_symbol("src/lib.rs::test_main", "src/lib.rs");
340 sym.is_test = true;
341 sym.kind = SymbolKind::Test;
342 let config = DeadCodeConfig {
343 include_tests: true,
344 ..DeadCodeConfig::default()
345 };
346 let result = detect_dead_code(&[sym], &[], &config);
347 assert_eq!(
348 result.dead_symbols.len(),
349 1,
350 "Test entry points should not be excluded via entry point layer"
351 );
352 }
353
354 #[test]
355 fn entry_point_patterns_add_exclusions() {
356 let sym = make_symbol("src/api.rs::handle_request", "src/api.rs");
357 let config = DeadCodeConfig {
358 entry_point_patterns: vec!["**::handle_*".into()],
359 ..DeadCodeConfig::default()
360 };
361 let result = detect_dead_code(&[sym], &[], &config);
362 assert_eq!(result.dead_symbols.len(), 0);
363 assert_eq!(result.summary.excluded_count, 1);
364 }
365
366 #[test]
367 fn summary_statistics_correct() {
368 let syms = vec![
369 make_symbol("src/a.rs::dead1", "src/a.rs"),
370 make_symbol("src/a.rs::dead2", "src/a.rs"),
371 make_symbol("src/b.rs::dead3", "src/b.rs"),
372 ];
373 let result = detect_dead_code(&syms, &[], &DeadCodeConfig::default());
374 assert_eq!(result.summary.total_symbols, 3);
375 assert_eq!(result.summary.dead_count, 3);
376 assert!((result.summary.dead_percentage - 100.0).abs() < f64::EPSILON);
377 assert_eq!(result.summary.dead_by_kind[&SymbolKind::Function], 3);
378 assert_eq!(result.summary.dead_by_file[0], ("src/a.rs".to_string(), 2));
380 assert_eq!(result.summary.dead_by_file[1], ("src/b.rs".to_string(), 1));
381 }
382
383 #[test]
384 fn empty_graph_returns_zero_percentage() {
385 let result = detect_dead_code(&[], &[], &DeadCodeConfig::default());
386 assert_eq!(result.summary.total_symbols, 0);
387 assert_eq!(result.summary.dead_count, 0);
388 assert!((result.summary.dead_percentage - 0.0).abs() < f64::EPSILON);
389 }
390
391 #[test]
392 fn exclusion_layer_order_first_match_wins() {
393 let mut sym = make_symbol("src/lib.rs::exported_test", "src/lib.rs");
394 sym.is_exported = true;
395 sym.is_test = true;
396 let config = DeadCodeConfig {
397 include_tests: true,
398 ..DeadCodeConfig::default()
399 };
400 let result = detect_dead_code(&[sym], &[], &config);
401 assert_eq!(
402 result.dead_symbols.len(),
403 0,
404 "exported symbol excluded regardless of include_tests"
405 );
406 assert_eq!(result.summary.excluded_count, 1);
407 }
408}