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 InappropriateIntimacyThresholds {
pub threshold_high: usize,
pub threshold_medium: usize,
pub min_mutual_access: usize,
}
impl Default for InappropriateIntimacyThresholds {
fn default() -> Self {
Self {
threshold_high: 40, threshold_medium: 20, min_mutual_access: 8, }
}
}
pub struct InappropriateIntimacyDetector {
config: DetectorConfig,
thresholds: InappropriateIntimacyThresholds,
}
impl InappropriateIntimacyDetector {
pub fn new() -> Self {
Self::with_thresholds(InappropriateIntimacyThresholds::default())
}
pub fn with_thresholds(thresholds: InappropriateIntimacyThresholds) -> Self {
Self {
config: DetectorConfig::new(),
thresholds,
}
}
pub fn with_config(config: DetectorConfig) -> Self {
let thresholds = InappropriateIntimacyThresholds {
threshold_high: config.get_option_or("threshold_high", 20),
threshold_medium: config.get_option_or("threshold_medium", 10),
min_mutual_access: config.get_option_or("min_mutual_access", 5),
};
Self { config, thresholds }
}
fn calculate_severity(&self, total_coupling: usize) -> Severity {
if total_coupling >= self.thresholds.threshold_high {
Severity::High
} else if total_coupling >= self.thresholds.threshold_medium {
Severity::Medium
} else {
Severity::Low
}
}
fn estimate_effort(&self, severity: Severity) -> String {
match severity {
Severity::Critical => "Large (8+ hours)".to_string(),
Severity::High => "Large (4-8 hours)".to_string(),
Severity::Medium => "Medium (2-4 hours)".to_string(),
Severity::Low | Severity::Info => "Medium (1-2 hours)".to_string(),
}
}
fn create_finding(
&self,
_class1: String,
class1_name: String,
_class2: String,
class2_name: String,
file1: String,
file2: String,
c1_to_c2: usize,
c2_to_c1: usize,
) -> Finding {
let total_coupling = c1_to_c2 + c2_to_c1;
let severity = self.calculate_severity(total_coupling);
let same_file = file1 == file2;
let same_file_note = if same_file {
" (same file)"
} else {
" (different files)"
};
let suggestion = if severity == Severity::High {
format!(
"Classes '{}' and '{}' have excessive mutual access ({} total accesses: \
{} and {} respectively).\n\n\
This tight coupling violates encapsulation. Consider:\n\
1. Merge the classes if they truly belong together\n\
2. Extract common data into a shared class\n\
3. Apply the Law of Demeter - don't access internals directly\n\
4. Introduce interfaces or abstract base classes to reduce coupling",
class1_name, class2_name, total_coupling, c1_to_c2, c2_to_c1
)
} else {
format!(
"Classes '{}' and '{}' show inappropriate intimacy ({} mutual accesses). \
Consider refactoring to reduce coupling.",
class1_name, class2_name, total_coupling
)
};
let mut affected_files = vec![PathBuf::from(&file1)];
if file1 != file2 {
affected_files.push(PathBuf::from(&file2));
}
Finding {
id: Uuid::new_v4().to_string(),
detector: "InappropriateIntimacyDetector".to_string(),
severity,
title: format!("Inappropriate Intimacy: {} ↔ {}", class1_name, class2_name),
description: format!(
"Classes '{}' and '{}' are too tightly coupled{}:\n\
• {} → {}: {} accesses\n\
• {} → {}: {} accesses\n\
• Total coupling: {} mutual accesses\n\n\
This bidirectional coupling makes both classes difficult to change independently \
and violates encapsulation principles.",
class1_name,
class2_name,
same_file_note,
class1_name,
class2_name,
c1_to_c2,
class2_name,
class1_name,
c2_to_c1,
total_coupling
),
affected_files,
line_start: None,
line_end: None,
suggested_fix: Some(suggestion),
estimated_effort: Some(self.estimate_effort(severity)),
category: Some("coupling".to_string()),
cwe_id: None,
why_it_matters: Some(
"Inappropriate intimacy makes classes hard to change independently. \
When two classes know too much about each other's internals, changes \
to one often require changes to the other, leading to ripple effects \
and increased maintenance costs."
.to_string(),
),
..Default::default()
}
}
}
impl Default for InappropriateIntimacyDetector {
fn default() -> Self {
Self::new()
}
}
impl Detector for InappropriateIntimacyDetector {
fn name(&self) -> &'static str {
"InappropriateIntimacyDetector"
}
fn description(&self) -> &'static str {
"Detects classes that are too tightly coupled"
}
fn category(&self) -> &'static str {
"coupling"
}
fn config(&self) -> Option<&DetectorConfig> {
Some(&self.config)
} fn detect(&self, graph: &GraphStore) -> Result<Vec<Finding>> {
let mut findings = Vec::new();
use std::collections::HashMap;
fn is_expected_layer_dependency(from: &str, to: &str) -> bool {
if from.contains("/cli/") { return true; }
if from.contains("/handlers/") || from.contains("/mcp/") { return true; }
if from.contains("/tests/") || from.contains("_test.rs") { return true; }
if from.ends_with("/mod.rs") { return true; }
false
}
let mut a_to_b: HashMap<(String, String), usize> = HashMap::new();
let mut b_to_a: HashMap<(String, String), usize> = HashMap::new();
for (caller, callee) in graph.get_calls() {
if let (Some(caller_node), Some(callee_node)) = (graph.get_node(&caller), graph.get_node(&callee)) {
if caller_node.file_path != callee_node.file_path {
if is_expected_layer_dependency(&caller_node.file_path, &callee_node.file_path) {
continue;
}
let key = if caller_node.file_path < callee_node.file_path {
(caller_node.file_path.clone(), callee_node.file_path.clone())
} else {
(callee_node.file_path.clone(), caller_node.file_path.clone())
};
if caller_node.file_path < callee_node.file_path {
*a_to_b.entry(key).or_insert(0) += 1;
} else {
*b_to_a.entry(key).or_insert(0) += 1;
}
}
}
}
for ((file_a, file_b), count_a_to_b) in &a_to_b {
let count_b_to_a = b_to_a.get(&(file_a.clone(), file_b.clone())).copied().unwrap_or(0);
if count_a_to_b >= &8 && count_b_to_a >= 8 {
let total = count_a_to_b + count_b_to_a;
let severity = if total >= 50 && count_b_to_a >= 15 && *count_a_to_b >= 15 {
Severity::High
} else if total >= 30 {
Severity::Medium
} else {
Severity::Low
};
findings.push(Finding {
id: Uuid::new_v4().to_string(),
detector: "InappropriateIntimacyDetector".to_string(),
severity,
title: format!("Inappropriate Intimacy"),
description: format!(
"Files have bidirectional coupling: {} → {} ({} calls) and {} → {} ({} calls). \
Consider merging or extracting shared logic.",
file_a, file_b, count_a_to_b, file_b, file_a, count_b_to_a
),
affected_files: vec![file_a.clone().into(), file_b.clone().into()],
line_start: None,
line_end: None,
suggested_fix: Some("Extract shared functionality into a separate module, or merge these files if they're truly one concept".to_string()),
estimated_effort: Some("Medium (2-4 hours)".to_string()),
category: Some("coupling".to_string()),
cwe_id: None,
why_it_matters: Some("Bidirectional coupling makes both files hard to change independently - a change in one often requires changes in the other".to_string()),
..Default::default()
});
}
}
Ok(findings)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_thresholds() {
let detector = InappropriateIntimacyDetector::new();
assert_eq!(detector.thresholds.threshold_high, 40);
assert_eq!(detector.thresholds.threshold_medium, 20);
assert_eq!(detector.thresholds.min_mutual_access, 8);
}
#[test]
fn test_severity_calculation() {
let detector = InappropriateIntimacyDetector::new();
assert_eq!(detector.calculate_severity(10), Severity::Low);
assert_eq!(detector.calculate_severity(20), Severity::Medium);
assert_eq!(detector.calculate_severity(30), Severity::Medium);
assert_eq!(detector.calculate_severity(40), Severity::High);
assert_eq!(detector.calculate_severity(60), Severity::High);
}
}