Skip to main content

tldr_cli/commands/bugbot/
dead.rs

1//! Born-dead detection for bugbot
2//!
3//! Detects new functions (added in the current changeset) that have zero
4//! references anywhere in the codebase.  Zero false positives by construction:
5//! if you just wrote a function and nothing calls it, it is dead.
6//!
7//! Uses the reference-counting approach from the `dead` command (single-pass
8//! identifier scan via tree-sitter) rather than the full call graph.
9
10use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12
13use anyhow::Result;
14use serde::{Deserialize, Serialize};
15
16use tldr_core::analysis::refcount::{count_identifiers_in_tree, is_rescued_by_refcount};
17use tldr_core::ast::parser::parse_file;
18use tldr_core::Language;
19
20use super::types::BugbotFinding;
21use crate::commands::dead::collect_module_infos_with_refcounts;
22use crate::commands::remaining::types::ASTChange;
23
24/// Evidence payload for a born-dead finding.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct BornDeadEvidence {
27    /// Whether the function has `pub` visibility.
28    pub is_public: bool,
29    /// Number of identifier occurrences across the project (1 = definition only).
30    pub ref_count: usize,
31}
32
33/// Check inserted functions for zero references (born dead).
34///
35/// # Arguments
36/// * `inserted` - AST changes of type Insert + Function/Method (from `diff::inserted_functions`)
37/// * `project`  - Project root directory (used to scan for refcounts)
38/// * `language` - Language of the project files
39///
40/// Only call this when there are Insert changes -- the refcount scan is the
41/// expensive part (~seconds for large projects).
42pub fn compose_born_dead(
43    inserted: &[&ASTChange],
44    project: &Path,
45    language: &Language,
46) -> Result<Vec<BugbotFinding>> {
47    if inserted.is_empty() {
48        return Ok(Vec::new());
49    }
50
51    // Scan the entire project for identifier refcounts (single-pass tree-sitter).
52    let (_module_infos, ref_counts) = collect_module_infos_with_refcounts(project, *language);
53
54    compose_born_dead_with_refcounts(inserted, &ref_counts)
55}
56
57/// Scoped born-dead detection: only scan changed files + their importers.
58///
59/// Instead of scanning the entire project (which takes minutes on large repos),
60/// this scans only the files that could possibly reference the new functions:
61/// - The changed files themselves (a new call must be in a changed file)
62/// - Files that import from changed modules (covers pre-existing call sites)
63///
64/// This reduces the scan from 847 files / 3+ minutes to ~30 files / <100ms
65/// on the TLDR codebase.
66pub fn compose_born_dead_scoped(
67    inserted: &[&ASTChange],
68    changed_files: &[PathBuf],
69    project: &Path,
70    language: &Language,
71) -> Result<Vec<BugbotFinding>> {
72    if inserted.is_empty() {
73        return Ok(Vec::new());
74    }
75
76    // Tier 1: Count identifiers in changed files only.
77    let mut ref_counts: HashMap<String, usize> = HashMap::new();
78    for file in changed_files {
79        if !file.exists() {
80            continue;
81        }
82        if let Ok((tree, source, lang)) = parse_file(file) {
83            let file_counts = count_identifiers_in_tree(&tree, source.as_bytes(), lang);
84            for (name, count) in file_counts {
85                *ref_counts.entry(name).or_insert(0) += count;
86            }
87        }
88    }
89
90    // Tier 2: Find files that import changed modules, scan those too.
91    // This catches the case where a new function satisfies a pre-existing
92    // call site in an unchanged file that imports the changed module.
93    let importer_files = find_importer_files(changed_files, project, language);
94    for file in &importer_files {
95        if !file.exists() {
96            continue;
97        }
98        // Skip files already scanned in tier 1
99        if changed_files.iter().any(|cf| cf == file) {
100            continue;
101        }
102        if let Ok((tree, source, lang)) = parse_file(file) {
103            let file_counts = count_identifiers_in_tree(&tree, source.as_bytes(), lang);
104            for (name, count) in file_counts {
105                *ref_counts.entry(name).or_insert(0) += count;
106            }
107        }
108    }
109
110    compose_born_dead_with_refcounts(inserted, &ref_counts)
111}
112
113/// Find files that import any of the changed modules.
114///
115/// For each changed file, derives its module name and calls `find_importers`
116/// to locate files that import it. Returns a deduplicated list of file paths.
117fn find_importer_files(
118    changed_files: &[PathBuf],
119    project: &Path,
120    language: &Language,
121) -> Vec<PathBuf> {
122    let mut importer_paths = Vec::new();
123    let mut seen = std::collections::HashSet::new();
124
125    for file in changed_files {
126        // Derive module name from file path relative to project root.
127        // e.g., "src/foo.rs" -> try "foo", "crate::foo", "src/foo"
128        let rel = file.strip_prefix(project).unwrap_or(file);
129        let module_candidates = derive_module_names(rel);
130
131        for module_name in &module_candidates {
132            if let Ok(report) =
133                tldr_core::analysis::importers::find_importers(project, module_name, *language)
134            {
135                for importer in &report.importers {
136                    let importer_path = project.join(&importer.file);
137                    if seen.insert(importer_path.clone()) {
138                        importer_paths.push(importer_path);
139                    }
140                }
141            }
142        }
143    }
144
145    importer_paths
146}
147
148/// Derive possible module names from a relative file path.
149///
150/// e.g., `src/utils/helpers.rs` -> `["helpers", "utils::helpers", "crate::utils::helpers"]`
151/// e.g., `lib.rs` -> `["lib"]`
152fn derive_module_names(rel_path: &Path) -> Vec<String> {
153    let mut names = Vec::new();
154    let stem = rel_path
155        .file_stem()
156        .and_then(|s| s.to_str())
157        .unwrap_or("");
158
159    if stem.is_empty() {
160        return names;
161    }
162
163    // Bare module name (e.g., "helpers")
164    names.push(stem.to_string());
165
166    // Build path-based module name, stripping src/ prefix and extension
167    let components: Vec<&str> = rel_path
168        .components()
169        .filter_map(|c| c.as_os_str().to_str())
170        .collect();
171
172    if components.len() > 1 {
173        // Skip common source directory prefixes
174        let skip = if components[0] == "src" || components[0] == "lib" {
175            1
176        } else {
177            0
178        };
179        let module_parts: Vec<&str> = components[skip..].to_vec();
180
181        // Replace file extension in last component with nothing
182        if let Some(last) = module_parts.last() {
183            let mut parts: Vec<String> = module_parts[..module_parts.len() - 1]
184                .iter()
185                .map(|s| s.to_string())
186                .collect();
187            let last_stem = Path::new(last)
188                .file_stem()
189                .and_then(|s| s.to_str())
190                .unwrap_or(last);
191            // Skip mod.rs / __init__.py — use parent as module name
192            if last_stem != "mod" && last_stem != "__init__" {
193                parts.push(last_stem.to_string());
194            }
195
196            if !parts.is_empty() {
197                // Qualified path (e.g., "utils::helpers")
198                let qualified = parts.join("::");
199                if qualified != stem {
200                    names.push(qualified.clone());
201                }
202                // Crate-prefixed (e.g., "crate::utils::helpers")
203                names.push(format!("crate::{}", qualified));
204            }
205        }
206    }
207
208    names
209}
210
211/// Inner implementation that accepts pre-computed refcounts.
212///
213/// Separated so tests can provide synthetic refcount maps without scanning a
214/// real project directory.
215pub fn compose_born_dead_with_refcounts(
216    inserted: &[&ASTChange],
217    ref_counts: &HashMap<String, usize>,
218) -> Result<Vec<BugbotFinding>> {
219    let mut findings = Vec::new();
220
221    for change in inserted {
222        let name = match change.name.as_deref() {
223            Some(n) => n,
224            None => continue, // no name => skip
225        };
226
227        // Skip test functions (they are entry points invoked by test runners)
228        if is_test_function(name) {
229            continue;
230        }
231
232        // Skip functions in test files — they are test infrastructure
233        // invoked by test harnesses, not dead code.
234        let file_path = change
235            .new_location
236            .as_ref()
237            .map(|loc| loc.file.as_str())
238            .unwrap_or("");
239        if is_test_file(file_path) {
240            continue;
241        }
242
243        // Skip standard entry points (main, etc.)
244        if is_entry_point(name) {
245            continue;
246        }
247
248        // Skip trait impl methods -- heuristic: check `new_text` for `impl ... for`
249        let new_text = change.new_text.as_deref().unwrap_or("");
250        if is_trait_impl(name, new_text) {
251            continue;
252        }
253
254        // Check refcount: if rescued (ref_count > 1, name >= 3 chars) => alive
255        if is_rescued_by_refcount(name, ref_counts) {
256            continue;
257        }
258
259        // Not rescued => born dead
260        let is_public = new_text.contains("pub fn ") || new_text.contains("pub async fn ");
261        let ref_count = lookup_ref_count(name, ref_counts);
262
263        let line = change
264            .new_location
265            .as_ref()
266            .map(|loc| loc.line as usize)
267            .unwrap_or(0);
268
269        let file = change
270            .new_location
271            .as_ref()
272            .map(|loc| PathBuf::from(&loc.file))
273            .unwrap_or_default();
274
275        let severity = if is_public { "medium" } else { "low" };
276
277        findings.push(BugbotFinding {
278            finding_type: "born-dead".to_string(),
279            severity: severity.to_string(),
280            file,
281            function: name.to_string(),
282            line,
283            message: format!(
284                "New function '{}' has no callers (ref_count: {})",
285                name, ref_count
286            ),
287            evidence: serde_json::to_value(&BornDeadEvidence {
288                is_public,
289                ref_count,
290            })
291            .unwrap_or_default(),
292            confidence: None,
293            finding_id: None,
294        });
295    }
296
297    Ok(findings)
298}
299
300/// Look up the refcount for a function name, handling qualified names.
301fn lookup_ref_count(name: &str, ref_counts: &HashMap<String, usize>) -> usize {
302    // Try bare name first (e.g. "method" from "Class.method")
303    let bare_name = if name.contains('.') {
304        name.rsplit('.').next().unwrap_or(name)
305    } else if name.contains(':') {
306        name.rsplit(':').next().unwrap_or(name)
307    } else {
308        name
309    };
310
311    if let Some(&count) = ref_counts.get(bare_name) {
312        return count;
313    }
314    if bare_name != name {
315        if let Some(&count) = ref_counts.get(name) {
316            return count;
317        }
318    }
319    0
320}
321
322/// Check if a function name looks like a test function.
323pub fn is_test_function(name: &str) -> bool {
324    name.starts_with("test_")
325        || name == "test"
326        || name.starts_with("Test")
327        || name.starts_with("Benchmark")
328        || name.starts_with("Example")
329}
330
331/// Check if a file path indicates a test file.
332///
333/// Covers common conventions across languages:
334/// - Rust: `tests/` directory, `_test.rs`, `_tests.rs`
335/// - Python: `test_*.py`, `*_test.py`, `tests/` directory
336/// - JS/TS: `*.test.ts`, `*.spec.ts`, `__tests__/`
337/// - Go: `*_test.go`
338/// - Java: `*Test.java`, `src/test/`
339fn is_test_file(path: &str) -> bool {
340    // Normalize separators for cross-platform matching
341    let path = path.replace('\\', "/");
342
343    // Directory-based patterns
344    if path.contains("/tests/")
345        || path.contains("/test/")
346        || path.contains("/__tests__/")
347        || path.contains("/testing/")
348    {
349        return true;
350    }
351
352    // File suffix patterns (extract the filename)
353    let filename = path.rsplit('/').next().unwrap_or(&path);
354    filename.ends_with("_test.rs")
355        || filename.ends_with("_tests.rs")
356        || filename.ends_with("_test.go")
357        || filename.ends_with("_test.py")
358        || filename.starts_with("test_")
359        || filename.ends_with(".test.ts")
360        || filename.ends_with(".test.tsx")
361        || filename.ends_with(".test.js")
362        || filename.ends_with(".test.jsx")
363        || filename.ends_with(".spec.ts")
364        || filename.ends_with(".spec.tsx")
365        || filename.ends_with(".spec.js")
366        || filename.ends_with(".spec.jsx")
367        || filename.ends_with("Test.java")
368        || filename.ends_with("Tests.java")
369}
370
371/// Check if a function name is a standard entry point.
372fn is_entry_point(name: &str) -> bool {
373    matches!(
374        name,
375        "main" | "__main__" | "cli" | "app" | "run" | "start" | "create_app" | "make_app" | "lib"
376    )
377}
378
379/// Name-based heuristic: suppress born-dead findings for common standard-library
380/// trait method names.
381///
382/// The `new_text` field in an `ASTChange` contains the function body, not the
383/// surrounding `impl` block, so we cannot inspect the impl context.  Instead we
384/// check the function name against known trait methods from `std` and popular
385/// crates (serde).  These methods are invoked polymorphically and almost never
386/// truly dead.
387///
388/// False negatives (a trait impl method we don't recognise) produce an extra
389/// finding the user can ignore.  False positives (silencing real dead code) are
390/// worse, so the list is kept conservative -- only names that are exclusively
391/// or overwhelmingly used as trait implementations.
392fn is_trait_impl(name: &str, _text: &str) -> bool {
393    matches!(
394        name,
395        "fmt" | "from" | "into" | "try_from" | "try_into"
396            | "clone" | "clone_from" | "default" | "drop"
397            | "deref" | "deref_mut" | "as_ref" | "as_mut"
398            | "borrow" | "borrow_mut"
399            | "eq" | "ne" | "partial_cmp" | "cmp" | "hash"
400            | "next" | "size_hint"
401            | "index" | "index_mut"
402            | "from_str" | "to_string" | "write_str"
403            | "serialize" | "deserialize"
404            | "poll"
405    )
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411    use crate::commands::remaining::types::{ChangeType, Location, NodeKind};
412
413    /// Helper to build an ASTChange representing an inserted function.
414    fn make_insert(name: &str, text: &str, file: &str, line: u32) -> ASTChange {
415        ASTChange {
416            change_type: ChangeType::Insert,
417            node_kind: NodeKind::Function,
418            name: Some(name.to_string()),
419            old_location: None,
420            new_location: Some(Location {
421                file: file.to_string(),
422                line,
423                column: 0,
424                end_line: None,
425                end_column: None,
426            }),
427            old_text: None,
428            new_text: Some(text.to_string()),
429            similarity: None,
430            children: None,
431            base_changes: None,
432        }
433    }
434
435    #[test]
436    fn test_born_dead_new_unused_function() {
437        let insert = make_insert(
438            "helper",
439            "fn helper() { println!(\"unused\"); }",
440            "src/lib.rs",
441            10,
442        );
443        let inserted: Vec<&ASTChange> = vec![&insert];
444
445        // refcounts: "helper" appears once (its definition)
446        let mut ref_counts = HashMap::new();
447        ref_counts.insert("helper".to_string(), 1);
448
449        let findings =
450            compose_born_dead_with_refcounts(&inserted, &ref_counts).expect("should succeed");
451
452        assert_eq!(findings.len(), 1, "Should detect one born-dead function");
453        assert_eq!(findings[0].finding_type, "born-dead");
454        assert_eq!(findings[0].function, "helper");
455        assert_eq!(findings[0].line, 10);
456    }
457
458    #[test]
459    fn test_born_dead_new_used_function() {
460        let insert = make_insert(
461            "helper",
462            "fn helper() { println!(\"used\"); }",
463            "src/lib.rs",
464            10,
465        );
466        let inserted: Vec<&ASTChange> = vec![&insert];
467
468        // refcounts: "helper" appears 3 times (definition + 2 call sites)
469        let mut ref_counts = HashMap::new();
470        ref_counts.insert("helper".to_string(), 3);
471
472        let findings =
473            compose_born_dead_with_refcounts(&inserted, &ref_counts).expect("should succeed");
474
475        assert!(
476            findings.is_empty(),
477            "Function with callers should not be flagged, got: {:?}",
478            findings
479        );
480    }
481
482    #[test]
483    fn test_born_dead_public_medium_severity() {
484        let insert = make_insert(
485            "unused_pub",
486            "pub fn unused_pub() { }",
487            "src/lib.rs",
488            5,
489        );
490        let inserted: Vec<&ASTChange> = vec![&insert];
491
492        let mut ref_counts = HashMap::new();
493        ref_counts.insert("unused_pub".to_string(), 1);
494
495        let findings =
496            compose_born_dead_with_refcounts(&inserted, &ref_counts).expect("should succeed");
497
498        assert_eq!(findings.len(), 1);
499        assert_eq!(
500            findings[0].severity, "medium",
501            "Public unused function should have medium severity"
502        );
503
504        // Verify evidence
505        let evidence: BornDeadEvidence =
506            serde_json::from_value(findings[0].evidence.clone()).expect("parse evidence");
507        assert!(evidence.is_public, "Evidence should mark as public");
508        assert_eq!(evidence.ref_count, 1);
509    }
510
511    #[test]
512    fn test_born_dead_private_low_severity() {
513        let insert = make_insert(
514            "unused_priv",
515            "fn unused_priv() { }",
516            "src/lib.rs",
517            5,
518        );
519        let inserted: Vec<&ASTChange> = vec![&insert];
520
521        let mut ref_counts = HashMap::new();
522        ref_counts.insert("unused_priv".to_string(), 1);
523
524        let findings =
525            compose_born_dead_with_refcounts(&inserted, &ref_counts).expect("should succeed");
526
527        assert_eq!(findings.len(), 1);
528        assert_eq!(
529            findings[0].severity, "low",
530            "Private unused function should have low severity"
531        );
532
533        let evidence: BornDeadEvidence =
534            serde_json::from_value(findings[0].evidence.clone()).expect("parse evidence");
535        assert!(!evidence.is_public, "Evidence should mark as private");
536    }
537
538    #[test]
539    fn test_born_dead_test_function_suppressed() {
540        let insert = make_insert(
541            "test_something",
542            "fn test_something() { assert!(true); }",
543            "tests/my_test.rs",
544            1,
545        );
546        let inserted: Vec<&ASTChange> = vec![&insert];
547
548        // Even with ref_count=1, test functions should be suppressed
549        let mut ref_counts = HashMap::new();
550        ref_counts.insert("test_something".to_string(), 1);
551
552        let findings =
553            compose_born_dead_with_refcounts(&inserted, &ref_counts).expect("should succeed");
554
555        assert!(
556            findings.is_empty(),
557            "Test functions should be suppressed, got: {:?}",
558            findings
559        );
560    }
561
562    #[test]
563    fn test_born_dead_main_suppressed() {
564        let insert = make_insert("main", "fn main() { }", "src/main.rs", 1);
565        let inserted: Vec<&ASTChange> = vec![&insert];
566
567        let mut ref_counts = HashMap::new();
568        ref_counts.insert("main".to_string(), 1);
569
570        let findings =
571            compose_born_dead_with_refcounts(&inserted, &ref_counts).expect("should succeed");
572
573        assert!(
574            findings.is_empty(),
575            "Entry point 'main' should be suppressed, got: {:?}",
576            findings
577        );
578    }
579
580    #[test]
581    fn test_born_dead_empty_inserted_list() {
582        let inserted: Vec<&ASTChange> = vec![];
583        let ref_counts = HashMap::new();
584
585        let findings =
586            compose_born_dead_with_refcounts(&inserted, &ref_counts).expect("should succeed");
587
588        assert!(findings.is_empty(), "Empty input should produce no findings");
589    }
590
591    #[test]
592    fn test_born_dead_no_name_skipped() {
593        // An ASTChange with no name should be silently skipped
594        let change = ASTChange {
595            change_type: ChangeType::Insert,
596            node_kind: NodeKind::Function,
597            name: None,
598            old_location: None,
599            new_location: Some(Location {
600                file: "src/lib.rs".to_string(),
601                line: 1,
602                column: 0,
603                end_line: None,
604                end_column: None,
605            }),
606            old_text: None,
607            new_text: Some("fn () { }".to_string()),
608            similarity: None,
609            children: None,
610            base_changes: None,
611        };
612        let inserted: Vec<&ASTChange> = vec![&change];
613        let ref_counts = HashMap::new();
614
615        let findings =
616            compose_born_dead_with_refcounts(&inserted, &ref_counts).expect("should succeed");
617
618        assert!(
619            findings.is_empty(),
620            "Change with no name should be skipped"
621        );
622    }
623
624    #[test]
625    fn test_born_dead_zero_refcount_means_dead() {
626        // Function not found in refcounts at all => ref_count=0 => dead
627        let insert = make_insert(
628            "orphan_func",
629            "fn orphan_func() { }",
630            "src/lib.rs",
631            20,
632        );
633        let inserted: Vec<&ASTChange> = vec![&insert];
634
635        // Empty refcounts -- function name not even found
636        let ref_counts = HashMap::new();
637
638        let findings =
639            compose_born_dead_with_refcounts(&inserted, &ref_counts).expect("should succeed");
640
641        assert_eq!(findings.len(), 1, "Function not in refcounts should be dead");
642        assert_eq!(findings[0].function, "orphan_func");
643
644        let evidence: BornDeadEvidence =
645            serde_json::from_value(findings[0].evidence.clone()).expect("parse evidence");
646        assert_eq!(evidence.ref_count, 0);
647    }
648
649    #[test]
650    fn test_born_dead_multiple_findings() {
651        let insert1 = make_insert("dead_one", "fn dead_one() { }", "src/a.rs", 1);
652        let insert2 = make_insert("alive_one", "fn alive_one() { }", "src/a.rs", 10);
653        let insert3 = make_insert("dead_two", "pub fn dead_two() { }", "src/b.rs", 5);
654
655        let inserted: Vec<&ASTChange> = vec![&insert1, &insert2, &insert3];
656
657        let mut ref_counts = HashMap::new();
658        ref_counts.insert("dead_one".to_string(), 1);
659        ref_counts.insert("alive_one".to_string(), 4);
660        ref_counts.insert("dead_two".to_string(), 1);
661
662        let findings =
663            compose_born_dead_with_refcounts(&inserted, &ref_counts).expect("should succeed");
664
665        assert_eq!(
666            findings.len(),
667            2,
668            "Should find 2 dead functions, got: {:?}",
669            findings
670                .iter()
671                .map(|f| &f.function)
672                .collect::<Vec<_>>()
673        );
674
675        let names: Vec<&str> = findings.iter().map(|f| f.function.as_str()).collect();
676        assert!(names.contains(&"dead_one"));
677        assert!(names.contains(&"dead_two"));
678        assert!(!names.contains(&"alive_one"));
679    }
680
681    #[test]
682    fn test_born_dead_benchmark_function_suppressed() {
683        let insert = make_insert(
684            "BenchmarkSomething",
685            "fn BenchmarkSomething() { }",
686            "benches/bench.rs",
687            1,
688        );
689        let inserted: Vec<&ASTChange> = vec![&insert];
690
691        let mut ref_counts = HashMap::new();
692        ref_counts.insert("BenchmarkSomething".to_string(), 1);
693
694        let findings =
695            compose_born_dead_with_refcounts(&inserted, &ref_counts).expect("should succeed");
696
697        assert!(
698            findings.is_empty(),
699            "Benchmark functions should be suppressed"
700        );
701    }
702
703    #[test]
704    fn test_born_dead_short_name_not_rescued() {
705        // Short names (< 3 chars) need >= 5 refs to be rescued.
706        // With count=4, still below threshold => dead.
707        let insert = make_insert("ab", "fn ab() { }", "src/lib.rs", 1);
708        let inserted: Vec<&ASTChange> = vec![&insert];
709
710        // ref_count=4 and name is too short (needs >= 5) => not rescued => dead
711        let mut ref_counts = HashMap::new();
712        ref_counts.insert("ab".to_string(), 4);
713
714        let findings =
715            compose_born_dead_with_refcounts(&inserted, &ref_counts).expect("should succeed");
716
717        assert_eq!(
718            findings.len(),
719            1,
720            "Short-named function with count < 5 should not be rescued"
721        );
722    }
723
724    #[test]
725    fn test_lookup_ref_count_qualified_name() {
726        let mut ref_counts = HashMap::new();
727        ref_counts.insert("method".to_string(), 3);
728
729        assert_eq!(lookup_ref_count("Class.method", &ref_counts), 3);
730        assert_eq!(lookup_ref_count("module:method", &ref_counts), 3);
731        assert_eq!(lookup_ref_count("method", &ref_counts), 3);
732        assert_eq!(lookup_ref_count("unknown", &ref_counts), 0);
733    }
734
735    #[test]
736    fn test_is_test_function_patterns() {
737        assert!(is_test_function("test_something"));
738        assert!(is_test_function("test"));
739        assert!(is_test_function("TestFoo"));
740        assert!(is_test_function("BenchmarkBar"));
741        assert!(is_test_function("ExampleBaz"));
742        assert!(!is_test_function("helper"));
743        assert!(!is_test_function("testing_mode")); // starts with "Test" check is case-sensitive
744        assert!(!is_test_function("contestant"));
745    }
746
747    #[test]
748    fn test_is_entry_point_patterns() {
749        assert!(is_entry_point("main"));
750        assert!(is_entry_point("lib"));
751        assert!(is_entry_point("__main__"));
752        assert!(is_entry_point("cli"));
753        assert!(!is_entry_point("helper"));
754        assert!(!is_entry_point("main_loop")); // exact match only
755    }
756
757    #[test]
758    fn test_is_trait_impl_std_trait_methods() {
759        // Standard library trait methods should be recognised
760        let std_methods = [
761            "fmt", "from", "into", "try_from", "try_into",
762            "clone", "clone_from", "default", "drop",
763            "deref", "deref_mut", "as_ref", "as_mut",
764            "borrow", "borrow_mut",
765            "eq", "ne", "partial_cmp", "cmp", "hash",
766            "next", "size_hint",
767            "index", "index_mut",
768            "from_str", "to_string", "write_str",
769            "serialize", "deserialize",
770            "poll",
771        ];
772        for method in &std_methods {
773            assert!(
774                is_trait_impl(method, ""),
775                "'{}' should be recognised as a trait impl method",
776                method
777            );
778        }
779    }
780
781    #[test]
782    fn test_is_trait_impl_non_trait_methods() {
783        // Regular function names should NOT be flagged as trait methods
784        assert!(!is_trait_impl("helper", ""));
785        assert!(!is_trait_impl("process_data", ""));
786        assert!(!is_trait_impl("run", ""));
787        assert!(!is_trait_impl("build", ""));
788        assert!(!is_trait_impl("new", ""));
789        assert!(!is_trait_impl("main", ""));
790        assert!(!is_trait_impl("calculate", ""));
791    }
792
793    #[test]
794    fn test_born_dead_trait_impl_method_suppressed() {
795        // A new function named "fmt" with no callers should be suppressed
796        // because it is likely a Display/Debug trait impl.
797        let insert = make_insert(
798            "fmt",
799            "fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { Ok(()) }",
800            "src/lib.rs",
801            10,
802        );
803        let inserted: Vec<&ASTChange> = vec![&insert];
804
805        let mut ref_counts = HashMap::new();
806        ref_counts.insert("fmt".to_string(), 1);
807
808        let findings =
809            compose_born_dead_with_refcounts(&inserted, &ref_counts).expect("should succeed");
810
811        assert!(
812            findings.is_empty(),
813            "Trait impl method 'fmt' should be suppressed, got: {:?}",
814            findings
815        );
816    }
817
818    #[test]
819    fn test_born_dead_test_file_suppressed() {
820        // A function in a test file should be suppressed even without test_ prefix
821        let insert = make_insert(
822            "validate_output_format",
823            "fn validate_output_format() { assert!(true); }",
824            "crates/cli/tests/integration_test.rs",
825            50,
826        );
827        let inserted: Vec<&ASTChange> = vec![&insert];
828        let ref_counts = HashMap::new();
829
830        let findings =
831            compose_born_dead_with_refcounts(&inserted, &ref_counts).expect("should succeed");
832
833        assert!(
834            findings.is_empty(),
835            "Function in test file should be suppressed, got: {:?}",
836            findings
837        );
838    }
839
840    #[test]
841    fn test_born_dead_test_directory_suppressed() {
842        // Functions in /tests/ directory should be suppressed
843        let insert = make_insert(
844            "setup_mock_server",
845            "fn setup_mock_server() {}",
846            "src/tests/helpers.rs",
847            10,
848        );
849        let inserted: Vec<&ASTChange> = vec![&insert];
850        let ref_counts = HashMap::new();
851
852        let findings =
853            compose_born_dead_with_refcounts(&inserted, &ref_counts).expect("should succeed");
854
855        assert!(
856            findings.is_empty(),
857            "Function in tests directory should be suppressed, got: {:?}",
858            findings
859        );
860    }
861
862    #[test]
863    fn test_is_test_file_patterns() {
864        // Positive cases
865        assert!(is_test_file("crates/cli/tests/integration_test.rs"));
866        assert!(is_test_file("src/tests/helpers.rs"));
867        assert!(is_test_file("src/test/java/FooTest.java"));
868        assert!(is_test_file("src/__tests__/foo.test.ts"));
869        assert!(is_test_file("tests/test_foo.py"));
870        assert!(is_test_file("pkg/handler_test.go"));
871        assert!(is_test_file("src/utils.spec.ts"));
872        assert!(is_test_file("FooTest.java"));
873        assert!(is_test_file("FooTests.java"));
874
875        // Negative cases
876        assert!(!is_test_file("src/lib.rs"));
877        assert!(!is_test_file("src/main.py"));
878        assert!(!is_test_file("src/testing_utils.rs")); // /testing/ dir, not this
879        assert!(!is_test_file("src/contest.rs"));
880    }
881}