use std::path::{Path, PathBuf};
use crate::assessment::analyzer::Analyzer;
use crate::assessment::{Finding, analyzer::AnalysisContext};
#[cfg(feature = "ecc")]
use crate::eml_kernel::ComplexityModel;
const DEFAULT_LINE_THRESHOLD: usize = 500;
pub struct ComplexityAnalyzer {
#[cfg(feature = "ecc")]
model: Option<ComplexityModel>,
}
impl Default for ComplexityAnalyzer {
fn default() -> Self {
Self::new()
}
}
impl ComplexityAnalyzer {
pub fn new() -> Self {
Self {
#[cfg(feature = "ecc")]
model: None,
}
}
#[cfg(feature = "ecc")]
pub fn with_model(model: ComplexityModel) -> Self {
Self { model: Some(model) }
}
#[cfg(feature = "ecc")]
pub fn model(&self) -> Option<&ComplexityModel> {
self.model.as_ref()
}
#[cfg(feature = "ecc")]
fn threshold_for(&self, lang_ordinal: u32, line_count: usize) -> usize {
match self.model.as_ref() {
Some(m) if m.is_trained() => {
m.predict(lang_ordinal, line_count as f64, 1.0)
}
_ => DEFAULT_LINE_THRESHOLD,
}
}
#[cfg(not(feature = "ecc"))]
fn threshold_for(&self, _lang_ordinal: u32, _line_count: usize) -> usize {
DEFAULT_LINE_THRESHOLD
}
}
impl Analyzer for ComplexityAnalyzer {
fn id(&self) -> &str {
"complexity"
}
fn name(&self) -> &str {
"Complexity Analyzer"
}
fn categories(&self) -> &[&str] {
&["size", "todo"]
}
fn analyze(
&self,
project: &Path,
files: &[PathBuf],
_context: &AnalysisContext,
) -> Vec<Finding> {
let mut findings = Vec::new();
for path in files {
let rel = path.strip_prefix(project).unwrap_or(path);
let rel_str = rel.display().to_string();
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => continue,
};
let line_count = content.lines().count();
let threshold = self.threshold_for(0, line_count);
if line_count > threshold {
findings.push(Finding {
severity: "warning".into(),
category: "size".into(),
file: rel_str.clone(),
line: None,
message: format!("File has {line_count} lines (>{threshold} limit)"),
});
}
for (i, line) in content.lines().enumerate() {
if line.contains("TODO") || line.contains("FIXME") || line.contains("HACK") {
findings.push(Finding {
severity: "info".into(),
category: "todo".into(),
file: rel_str.clone(),
line: Some(i + 1),
message: line.trim().to_string(),
});
}
}
}
findings
}
}
#[cfg(test)]
mod tests {
use super::*;
fn write_file(dir: &Path, name: &str, lines: usize) -> PathBuf {
let p = dir.join(name);
let body: String = (0..lines).map(|i| format!("line {i}\n")).collect();
std::fs::write(&p, body).unwrap();
p
}
#[test]
fn analyzer_default_uses_500_threshold() {
let dir = tempfile::tempdir().unwrap();
let big = write_file(dir.path(), "big.txt", 600);
let small = write_file(dir.path(), "small.txt", 100);
let analyzer = ComplexityAnalyzer::new();
let ctx = AnalysisContext::default();
let findings = analyzer.analyze(dir.path(), &[big.clone(), small.clone()], &ctx);
let size_findings: Vec<_> = findings.iter().filter(|f| f.category == "size").collect();
assert_eq!(size_findings.len(), 1);
assert!(size_findings[0].message.contains("600 lines"));
assert!(size_findings[0].message.contains(">500 limit"));
}
#[test]
#[cfg(feature = "ecc")]
fn analyzer_untrained_model_matches_default() {
let dir = tempfile::tempdir().unwrap();
let big = write_file(dir.path(), "b.txt", 700);
let baseline = ComplexityAnalyzer::new();
let model = ComplexityModel::new();
assert!(!model.is_trained());
let wired = ComplexityAnalyzer::with_model(model);
let ctx = AnalysisContext::default();
let baseline_findings = baseline.analyze(dir.path(), std::slice::from_ref(&big), &ctx);
let wired_findings = wired.analyze(dir.path(), std::slice::from_ref(&big), &ctx);
assert_eq!(baseline_findings.len(), wired_findings.len());
for (a, b) in baseline_findings.iter().zip(wired_findings.iter()) {
assert_eq!(a.message, b.message);
assert_eq!(a.severity, b.severity);
assert_eq!(a.category, b.category);
}
}
#[test]
#[cfg(feature = "ecc")]
fn analyzer_trained_model_can_change_threshold() {
let dir = tempfile::tempdir().unwrap();
let medium = write_file(dir.path(), "m.txt", 400);
let baseline = ComplexityAnalyzer::new();
let baseline_findings = baseline.analyze(
dir.path(),
std::slice::from_ref(&medium),
&AnalysisContext::default(),
);
let baseline_size = baseline_findings
.iter()
.filter(|f| f.category == "size")
.count();
assert_eq!(baseline_size, 0);
let model = ComplexityModel::new();
let mut json = serde_json::to_value(&model).unwrap();
if let Some(inner) = json.get_mut("inner").and_then(|v| v.as_object_mut()) {
inner.insert("trained".into(), serde_json::Value::Bool(true));
}
let forced: ComplexityModel = serde_json::from_value(json).unwrap();
assert!(forced.is_trained());
let wired = ComplexityAnalyzer::with_model(forced);
let wired_findings = wired.analyze(
dir.path(),
std::slice::from_ref(&medium),
&AnalysisContext::default(),
);
let size_msgs: Vec<&str> = wired_findings
.iter()
.filter(|f| f.category == "size")
.map(|f| f.message.as_str())
.collect();
for msg in size_msgs {
assert!(
!msg.contains(">500 limit"),
"trained model must not produce hardcoded 500-line message: {msg}"
);
}
}
}