use crate::detectors::base::{Detector, DetectorConfig};
use crate::graph::GraphStore;
use crate::models::{Finding, Severity};
use anyhow::Result;
use std::path::PathBuf;
use tracing::{debug, info};
use uuid::Uuid;
#[derive(Debug, Clone)]
pub struct LazyClassThresholds {
pub max_methods: usize,
pub max_avg_loc_per_method: usize,
pub min_total_loc: usize,
}
impl Default for LazyClassThresholds {
fn default() -> Self {
Self {
max_methods: 3,
max_avg_loc_per_method: 5,
min_total_loc: 10,
}
}
}
static EXCLUDE_PATTERNS: &[&str] = &[
"Adapter",
"Wrapper",
"Proxy",
"Decorator",
"Facade",
"Bridge",
"Config",
"Settings",
"Options",
"Preferences",
"Request",
"Response",
"DTO",
"Entity",
"Model",
"Exception",
"Error",
"Base",
"Abstract",
"Interface",
"Mixin",
"Test",
"Mock",
"Stub",
"Fake",
"Protocol",
];
pub struct LazyClassDetector {
config: DetectorConfig,
thresholds: LazyClassThresholds,
}
impl LazyClassDetector {
pub fn new() -> Self {
Self::with_thresholds(LazyClassThresholds::default())
}
pub fn with_thresholds(thresholds: LazyClassThresholds) -> Self {
Self {
config: DetectorConfig::new(),
thresholds,
}
}
pub fn with_config(config: DetectorConfig) -> Self {
let thresholds = LazyClassThresholds {
max_methods: config.get_option_or("max_methods", 3),
max_avg_loc_per_method: config.get_option_or("max_avg_loc_per_method", 5),
min_total_loc: config.get_option_or("min_total_loc", 10),
};
Self { config, thresholds }
}
fn should_exclude(&self, class_name: &str) -> bool {
if class_name.is_empty() {
return true;
}
let class_lower = class_name.to_lowercase();
EXCLUDE_PATTERNS
.iter()
.any(|pattern| class_lower.contains(&pattern.to_lowercase()))
}
fn create_finding(
&self,
_qualified_name: String,
class_name: String,
file_path: String,
line_start: Option<u32>,
line_end: Option<u32>,
method_count: usize,
total_loc: usize,
avg_method_loc: f64,
) -> Finding {
Finding {
id: Uuid::new_v4().to_string(),
detector: "LazyClassDetector".to_string(),
severity: Severity::Low,
title: format!("Lazy class: {}", class_name),
description: format!(
"Class '{}' has only {} method(s) with an average of {:.1} lines each \
({} total LOC). This may indicate unnecessary abstraction.",
class_name, method_count, avg_method_loc, total_loc
),
affected_files: vec![PathBuf::from(&file_path)],
line_start,
line_end,
suggested_fix: Some(
"Consider one of the following:\n\
1. Inline this class's functionality into its callers\n\
2. Expand the class with additional functionality\n\
3. If this is a deliberate design pattern (Adapter, Facade), \
add a docstring explaining its purpose"
.to_string(),
),
estimated_effort: Some("Small (15-30 minutes)".to_string()),
category: Some("design".to_string()),
cwe_id: None,
why_it_matters: Some(
"Lazy classes add cognitive overhead without providing value. \
They increase indirection and make the codebase harder to navigate. \
If a class doesn't justify its existence with meaningful behavior, \
consider removing or expanding it."
.to_string(),
),
..Default::default()
}
}
}
impl Default for LazyClassDetector {
fn default() -> Self {
Self::new()
}
}
impl Detector for LazyClassDetector {
fn name(&self) -> &'static str {
"LazyClassDetector"
}
fn description(&self) -> &'static str {
"Detects classes that do minimal work"
}
fn category(&self) -> &'static str {
"design"
}
fn config(&self) -> Option<&DetectorConfig> {
Some(&self.config)
} fn detect(&self, graph: &GraphStore) -> Result<Vec<Finding>> {
let mut findings = Vec::new();
for class in graph.get_classes() {
if class.qualified_name.contains("::interface::") || class.qualified_name.contains("::type::") {
continue;
}
let method_count = class.get_i64("methodCount").unwrap_or(0) as usize;
let loc = class.loc() as usize;
if method_count <= 2 && loc < 50 && loc > 5 {
if class.name.ends_with("Error") || class.name.ends_with("Exception")
|| class.name.contains("Mixin") || class.name.starts_with("Base") {
continue;
}
findings.push(Finding {
id: Uuid::new_v4().to_string(),
detector: "LazyClassDetector".to_string(),
severity: Severity::Low,
title: format!("Lazy Class: {}", class.name),
description: format!(
"Class '{}' has only {} methods and {} LOC. Consider inlining or merging.",
class.name, method_count, loc
),
affected_files: vec![class.file_path.clone().into()],
line_start: Some(class.line_start),
line_end: Some(class.line_end),
suggested_fix: Some("Consider merging with another class or converting to functions".to_string()),
estimated_effort: Some("Small (30 min)".to_string()),
category: Some("structure".to_string()),
cwe_id: None,
why_it_matters: Some("Lazy classes add complexity without providing value".to_string()),
..Default::default()
});
}
}
Ok(findings)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_thresholds() {
let detector = LazyClassDetector::new();
assert_eq!(detector.thresholds.max_methods, 3);
assert_eq!(detector.thresholds.max_avg_loc_per_method, 5);
assert_eq!(detector.thresholds.min_total_loc, 10);
}
#[test]
fn test_should_exclude() {
let detector = LazyClassDetector::new();
assert!(detector.should_exclude("UserAdapter"));
assert!(detector.should_exclude("DatabaseConfig"));
assert!(detector.should_exclude("TestHelper"));
assert!(detector.should_exclude("CustomException"));
assert!(detector.should_exclude("BaseClass"));
assert!(!detector.should_exclude("UserService"));
assert!(!detector.should_exclude("OrderProcessor"));
}
}