use crate::detectors::base::Detector;
use crate::models::{deterministic_finding_id, Finding, Severity};
use anyhow::Result;
use regex::Regex;
use std::path::PathBuf;
use std::sync::LazyLock;
use tracing::info;
use super::is_test_context;
static PANIC_MACRO: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\bpanic!\s*\(").expect("valid regex"));
const FUNCTION_THRESHOLD: usize = 3;
const FILE_THRESHOLD: usize = 10;
#[derive(Debug)]
struct FunctionSpan {
name: String,
start_line: usize,
panic_count: usize,
panic_lines: Vec<usize>,
}
pub struct PanicDensityDetector {
#[allow(dead_code)] repository_path: PathBuf,
max_findings: usize,
}
impl PanicDensityDetector {
pub fn new(repository_path: impl Into<PathBuf>) -> Self {
Self {
repository_path: repository_path.into(),
max_findings: 30,
}
}
}
impl Detector for PanicDensityDetector {
fn name(&self) -> &'static str {
"rust-panic-density"
}
fn description(&self) -> &'static str {
"Detects Rust files/functions with a high density of .unwrap(), .expect(), and panic!() calls"
}
fn requires_graph(&self) -> bool {
false
}
fn file_extensions(&self) -> &'static [&'static str] {
&["rs"]
}
fn detect(
&self,
ctx: &crate::detectors::analysis_context::AnalysisContext,
) -> Result<Vec<Finding>> {
let files = &ctx.as_file_provider();
let mut findings = Vec::new();
for path in files.files_with_extension("rs") {
if findings.len() >= self.max_findings {
break;
}
let Some(content) = files.content(path) else {
continue;
};
let file_str = path.to_string_lossy();
let lines: Vec<&str> = content.lines().collect();
let functions = extract_function_spans(&lines, &content);
for func in &functions {
if func.panic_count > FUNCTION_THRESHOLD && findings.len() < self.max_findings {
let line_num = (func.start_line + 1) as u32;
let locations: Vec<String> = func
.panic_lines
.iter()
.map(|l| format!("line {}", l + 1))
.collect();
let title = format!(
"High panic density in `{}`: {} panic-family calls",
func.name, func.panic_count
);
findings.push(Finding {
id: deterministic_finding_id(
"PanicDensityDetector",
&file_str,
line_num,
&title,
),
detector: "PanicDensityDetector".to_string(),
severity: Severity::Medium,
title,
description: format!(
"Function `{}` contains {} calls to `.unwrap()`, `.expect()`, or `panic!()` at {}. \
This makes the function fragile and likely to panic at runtime.",
func.name,
func.panic_count,
locations.join(", ")
),
affected_files: vec![path.to_path_buf()],
line_start: Some(line_num),
line_end: None,
suggested_fix: Some(
"Reduce panic points by:\n\
- Using `?` operator for error propagation\n\
- Using `unwrap_or` / `unwrap_or_default` / `unwrap_or_else`\n\
- Returning `Result<T, E>` instead of panicking\n\
- Extracting fallible logic into a helper that returns Result"
.to_string(),
),
estimated_effort: Some("30 minutes".to_string()),
category: Some("reliability".to_string()),
why_it_matters: Some(
"Functions with many panic points are fragile. A single unexpected \
None/Err will crash the program. Consolidating error handling makes \
code more robust and easier to reason about."
.to_string(),
),
..Default::default()
});
}
}
let total_file_panics: usize = functions.iter().map(|f| f.panic_count).sum();
if total_file_panics > FILE_THRESHOLD && findings.len() < self.max_findings {
let title = format!(
"High file-level panic density: {} panic-family calls",
total_file_panics
);
findings.push(Finding {
id: deterministic_finding_id(
"PanicDensityDetector-file",
&file_str,
0,
&title,
),
detector: "PanicDensityDetector".to_string(),
severity: Severity::Low,
title,
description: format!(
"File `{}` contains {} total panic-family calls (`.unwrap()`, `.expect()`, `panic!()`) \
outside of test code. Consider refactoring to reduce panic surface.",
file_str, total_file_panics
),
affected_files: vec![path.to_path_buf()],
line_start: None,
line_end: None,
suggested_fix: Some(
"Consider:\n\
- Introducing a module-level error type\n\
- Replacing unwrap chains with `?` propagation\n\
- Using `anyhow::Context` for better error messages"
.to_string(),
),
estimated_effort: Some("1-2 hours".to_string()),
category: Some("reliability".to_string()),
why_it_matters: Some(
"Files with many panic points are hard to maintain and make the overall \
application fragile. Centralizing error handling improves reliability."
.to_string(),
),
..Default::default()
});
}
}
info!("PanicDensityDetector found {} findings", findings.len());
Ok(findings)
}
}
fn extract_function_spans(lines: &[&str], content: &str) -> Vec<FunctionSpan> {
let unwrap_re = &*super::UNWRAP_CALL;
let expect_re = &*super::EXPECT_CALL;
let panic_re = &*PANIC_MACRO;
let test_context = super::precompute_test_context(lines);
let mut functions: Vec<FunctionSpan> = Vec::new();
let mut current_fn: Option<FunctionSpan> = None;
let mut brace_depth: i32 = 0;
let mut fn_brace_start: i32 = 0;
for (i, line) in lines.iter().enumerate() {
if test_context[i] {
if current_fn.is_some() {
current_fn = None;
}
continue;
}
let trimmed = line.trim();
if current_fn.is_none() {
if let Some(fn_name) = parse_fn_name(trimmed) {
current_fn = Some(FunctionSpan {
name: fn_name,
start_line: i,
panic_count: 0,
panic_lines: Vec::new(),
});
fn_brace_start = brace_depth;
}
}
for ch in line.chars() {
match ch {
'{' => brace_depth += 1,
'}' => brace_depth -= 1,
_ => {}
}
}
if let Some(ref mut func) = current_fn {
let is_panic_line =
unwrap_re.is_match(line) || expect_re.is_match(line) || panic_re.is_match(line);
if is_panic_line {
let prev_line = if i > 0 { Some(lines[i - 1]) } else { None };
if !crate::detectors::is_line_suppressed(line, prev_line) {
if !super::is_safe_unwrap_context(line, content, i) {
func.panic_count += 1;
func.panic_lines.push(i);
}
}
}
if brace_depth <= fn_brace_start && i > func.start_line {
let completed = current_fn.take().expect("checked Some above");
functions.push(completed);
}
}
}
if let Some(func) = current_fn.take() {
functions.push(func);
}
functions
}
fn parse_fn_name(trimmed: &str) -> Option<String> {
if trimmed.starts_with("//") || trimmed.starts_with("/*") {
return None;
}
let fn_idx = if trimmed.starts_with("fn ") {
Some(3)
} else {
trimmed.find(" fn ").map(|idx| idx + 4)
};
let fn_idx = fn_idx?;
let rest = &trimmed[fn_idx..];
let name_end = rest.find(|c: char| !c.is_alphanumeric() && c != '_')?;
let name = &rest[..name_end];
if name.is_empty() {
return None;
}
Some(name.to_string())
}
impl super::super::RegisteredDetector for PanicDensityDetector {
fn create(init: &super::super::DetectorInit) -> std::sync::Arc<dyn Detector> {
std::sync::Arc::new(Self::new(init.repo_path))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::detectors::base::Detector;
use crate::graph::builder::GraphBuilder;
#[test]
fn test_function_above_threshold() {
let graph = GraphBuilder::new().freeze();
let detector = PanicDensityDetector::new("/mock/repo");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(&graph, vec![
("test.rs", "\nfn fragile() {\n let a = foo().unwrap();\n let b = bar().unwrap();\n let c = baz().unwrap();\n let d = qux().expect(\"oops\");\n}\n"),
]);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert_eq!(findings.len(), 1, "should flag function with 4 panic calls");
assert_eq!(findings[0].severity, Severity::Medium);
assert!(findings[0].title.contains("fragile"));
assert!(findings[0].title.contains("4"));
}
#[test]
fn test_function_at_threshold_not_flagged() {
let graph = GraphBuilder::new().freeze();
let detector = PanicDensityDetector::new("/mock/repo");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(&graph, vec![
("test.rs", "\nfn borderline() {\n let a = foo().unwrap();\n let b = bar().unwrap();\n let c = baz().unwrap();\n}\n"),
]);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(findings.is_empty(), "3 calls should not be flagged");
}
#[test]
fn test_file_level_threshold() {
let graph = GraphBuilder::new().freeze();
let detector = PanicDensityDetector::new("/mock/repo");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(&graph, vec![
("test.rs", "\nfn one() {\n let a = foo().unwrap();\n let b = bar().unwrap();\n let c = baz().unwrap();\n}\nfn two() {\n let a = foo().unwrap();\n let b = bar().unwrap();\n let c = baz().unwrap();\n}\nfn three() {\n let a = foo().unwrap();\n let b = bar().unwrap();\n let c = baz().unwrap();\n}\nfn four() {\n let a = foo().unwrap();\n let b = bar().unwrap();\n}\n"),
]);
let findings = detector.detect(&ctx).expect("detection should succeed");
let file_findings: Vec<_> = findings
.iter()
.filter(|f| f.title.contains("file-level"))
.collect();
assert_eq!(
file_findings.len(),
1,
"should flag file with 11 panic calls"
);
assert_eq!(file_findings[0].severity, Severity::Low);
}
#[test]
fn test_test_code_skipped() {
let graph = GraphBuilder::new().freeze();
let detector = PanicDensityDetector::new("/mock/repo");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(&graph, vec![
("test.rs", "\n#[cfg(test)]\nmod tests {\n fn test_something() {\n let a = foo().unwrap();\n let b = bar().unwrap();\n let c = baz().unwrap();\n let d = qux().unwrap();\n let e = quux().unwrap();\n }\n}\n"),
]);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(findings.is_empty(), "test code should be skipped");
}
#[test]
fn test_panic_macro_counted() {
let graph = GraphBuilder::new().freeze();
let detector = PanicDensityDetector::new("/mock/repo");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(&graph, vec![
("test.rs", "\nfn panicky() {\n if bad { panic!(\"oh no\"); }\n let a = foo().unwrap();\n let b = bar().unwrap();\n panic!(\"fatal\");\n}\n"),
]);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert_eq!(findings.len(), 1);
assert!(findings[0].title.contains("4"));
}
#[test]
fn test_parse_fn_name() {
assert_eq!(parse_fn_name("fn foo(bar: i32) {"), Some("foo".to_string()));
assert_eq!(
parse_fn_name("pub fn my_func() {"),
Some("my_func".to_string())
);
assert_eq!(
parse_fn_name("pub(crate) fn internal() {"),
Some("internal".to_string())
);
assert_eq!(parse_fn_name("// fn commented() {"), None);
assert_eq!(parse_fn_name("let x = 5;"), None);
}
#[test]
fn test_safe_unwrap_not_counted() {
let graph = GraphBuilder::new().freeze();
let detector = PanicDensityDetector::new("/mock/repo");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(&graph, vec![
("test.rs", "\nfn init() {\n REGEX.get_or_init(|| make_regex().unwrap());\n do_stuff_a();\n do_stuff_b();\n do_stuff_c();\n do_stuff_d();\n let a = foo().unwrap();\n let b = bar().unwrap();\n let c = baz().unwrap();\n let d = qux().unwrap();\n}\n"),
]);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert_eq!(findings.len(), 1);
assert!(
findings[0].title.contains("4"),
"should count 4 non-safe panics"
);
}
#[test]
fn test_no_findings_for_clean_code() {
let graph = GraphBuilder::new().freeze();
let detector = PanicDensityDetector::new("/mock/repo");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(&graph, vec![
("test.rs", "\nfn clean() -> Result<(), Error> {\n let a = foo()?;\n let b = bar().unwrap_or_default();\n Ok(())\n}\n"),
]);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(findings.is_empty());
}
}