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 RefusedBequestThresholds {
pub min_overrides: usize,
pub max_parent_call_ratio: f64,
}
impl Default for RefusedBequestThresholds {
fn default() -> Self {
Self {
min_overrides: 2,
max_parent_call_ratio: 0.3,
}
}
}
static EXCLUDE_PARENT_PATTERNS: &[&str] = &[
"ABC",
"Abstract",
"Interface",
"Base",
"Mixin",
"Protocol",
];
pub struct RefusedBequestDetector {
config: DetectorConfig,
thresholds: RefusedBequestThresholds,
}
impl RefusedBequestDetector {
pub fn new() -> Self {
Self::with_thresholds(RefusedBequestThresholds::default())
}
pub fn with_thresholds(thresholds: RefusedBequestThresholds) -> Self {
Self {
config: DetectorConfig::new(),
thresholds,
}
}
pub fn with_config(config: DetectorConfig) -> Self {
let thresholds = RefusedBequestThresholds {
min_overrides: config.get_option_or("min_overrides", 2),
max_parent_call_ratio: config.get_option_or("max_parent_call_ratio", 0.3),
};
Self { config, thresholds }
}
fn is_abstract_parent(&self, parent_name: &str) -> bool {
if parent_name.is_empty() {
return false;
}
let parent_lower = parent_name.to_lowercase();
EXCLUDE_PARENT_PATTERNS
.iter()
.any(|pattern| parent_lower.contains(&pattern.to_lowercase()))
}
fn calculate_severity(&self, ratio: f64) -> Severity {
if ratio == 0.0 {
Severity::High
} else if ratio < 0.2 {
Severity::Medium
} else {
Severity::Low
}
}
fn estimate_effort(&self, severity: Severity) -> String {
match severity {
Severity::Critical | Severity::High => "Medium (2-4 hours)".to_string(),
Severity::Medium => "Medium (1-2 hours)".to_string(),
Severity::Low | Severity::Info => "Small (30-60 minutes)".to_string(),
}
}
fn create_finding(
&self,
_child_name: String,
child_class: String,
_parent_name: String,
parent_class: String,
file_path: String,
line_start: Option<u32>,
line_end: Option<u32>,
total_overrides: usize,
overrides_calling_parent: usize,
) -> Finding {
let ratio = if total_overrides > 0 {
overrides_calling_parent as f64 / total_overrides as f64
} else {
0.0
};
let severity = self.calculate_severity(ratio);
let severity_reason = if ratio == 0.0 {
"No overrides call parent"
} else if ratio < 0.2 {
&format!("Only {:.0}% of overrides call parent", ratio * 100.0)
} else {
&format!("{:.0}% of overrides call parent", ratio * 100.0)
};
let parent_lower = parent_class.to_lowercase();
let recommendation = format!(
"Consider refactoring to use composition instead of inheritance:\n\
1. Replace `class {}({})` with `class {}`\n\
2. Add `{}` as a member: `self.{} = {}()`\n\
3. Delegate only the methods you actually need\n\n\
Benefits: Looser coupling, clearer intent, easier testing",
child_class, parent_class, child_class, parent_lower, parent_lower, parent_class
);
Finding {
id: Uuid::new_v4().to_string(),
detector: "RefusedBequestDetector".to_string(),
severity,
title: format!("Refused bequest: {} inherits {}", child_class, parent_class),
description: format!(
"Class '{}' inherits from '{}' but overrides {} method(s) with only {} \
calling the parent ({:.0}%). {}. This suggests inheritance may be misused.",
child_class,
parent_class,
total_overrides,
overrides_calling_parent,
ratio * 100.0,
severity_reason
),
affected_files: vec![PathBuf::from(&file_path)],
line_start,
line_end,
suggested_fix: Some(recommendation),
estimated_effort: Some(self.estimate_effort(severity)),
category: Some("design".to_string()),
cwe_id: None,
why_it_matters: Some(
"Refused bequest violates the Liskov Substitution Principle. \
When a subclass overrides parent methods without calling super(), \
it suggests the inheritance relationship is incorrect. Using composition \
instead of inheritance leads to more flexible and maintainable code."
.to_string(),
),
..Default::default()
}
}
}
impl Default for RefusedBequestDetector {
fn default() -> Self {
Self::new()
}
}
impl Detector for RefusedBequestDetector {
fn name(&self) -> &'static str {
"RefusedBequestDetector"
}
fn description(&self) -> &'static str {
"Detects classes that inherit but don't use parent functionality"
}
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 (child_qn, parent_qn) in graph.get_inheritance() {
if parent_qn.contains("Base") || parent_qn.contains("Abstract") || parent_qn.contains("Mixin") {
continue;
}
if let Some(child) = graph.get_node(&child_qn) {
let child_methods: Vec<_> = graph.get_functions()
.into_iter()
.filter(|f| f.qualified_name.starts_with(&child_qn))
.collect();
if child_methods.len() >= 3 {
let potential_refusals: Vec<_> = child_methods.iter()
.filter(|m| m.complexity().unwrap_or(1) <= 2 && m.loc() <= 5)
.collect();
if potential_refusals.len() >= 2 {
findings.push(Finding {
id: Uuid::new_v4().to_string(),
detector: "RefusedBequestDetector".to_string(),
severity: Severity::Low,
title: format!("Refused Bequest: {}", child.name),
description: format!(
"Class '{}' inherits from '{}' but may not use inherited behavior properly.",
child.name, parent_qn
),
affected_files: vec![child.file_path.clone().into()],
line_start: Some(child.line_start),
line_end: Some(child.line_end),
suggested_fix: Some("Consider composition over inheritance if not using parent behavior".to_string()),
estimated_effort: Some("Medium (1-2 hours)".to_string()),
category: Some("structure".to_string()),
cwe_id: None,
why_it_matters: Some("Refused bequest indicates improper use of inheritance".to_string()),
..Default::default()
});
}
}
}
}
Ok(findings)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_thresholds() {
let detector = RefusedBequestDetector::new();
assert_eq!(detector.thresholds.min_overrides, 2);
assert!((detector.thresholds.max_parent_call_ratio - 0.3).abs() < f64::EPSILON);
}
#[test]
fn test_is_abstract_parent() {
let detector = RefusedBequestDetector::new();
assert!(detector.is_abstract_parent("ABC"));
assert!(detector.is_abstract_parent("AbstractBase"));
assert!(detector.is_abstract_parent("BaseClass"));
assert!(detector.is_abstract_parent("UserInterface"));
assert!(detector.is_abstract_parent("MyMixin"));
assert!(!detector.is_abstract_parent("User"));
assert!(!detector.is_abstract_parent("OrderService"));
}
#[test]
fn test_severity_calculation() {
let detector = RefusedBequestDetector::new();
assert_eq!(detector.calculate_severity(0.0), Severity::High);
assert_eq!(detector.calculate_severity(0.1), Severity::Medium);
assert_eq!(detector.calculate_severity(0.2), Severity::Low);
assert_eq!(detector.calculate_severity(0.5), Severity::Low);
}
}