use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::Instant;
use indexmap::IndexMap;
use serde::{Deserialize, Serialize, Serializer};
use walkdir::WalkDir;
use crate::ast::extract::extract_file;
use crate::callgraph::build_project_call_graph;
use crate::error::TldrError;
use crate::types::{Language, ModuleInfo};
use crate::TldrResult;
use super::cohesion::{analyze_cohesion, CohesionReport};
use super::complexity::{analyze_complexity, ComplexityOptions, ComplexityReport};
use super::coupling::{analyze_coupling_with_graph, CouplingOptions, CouplingReport};
use super::dead_code::{analyze_dead_code_with_refcount, DeadCodeReport};
use super::martin::{compute_martin_metrics, MartinReport};
use crate::analysis::clones::{detect_clones, ClonesOptions};
pub use super::smells::ThresholdPreset;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[repr(u8)]
pub enum Severity {
#[default]
Info = 0,
Low = 1,
Medium = 2,
High = 3,
Critical = 4,
}
impl Severity {
pub fn as_u8(&self) -> u8 {
*self as u8
}
pub fn is_at_least(&self, threshold: Severity) -> bool {
*self >= threshold
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct HealthOptions {
pub quick: bool,
pub preset: ThresholdPreset,
pub complexity_threshold: usize,
pub cohesion_threshold: usize,
pub similarity_threshold: f64,
pub max_items: usize,
#[serde(skip)]
pub summary: bool,
}
impl Default for HealthOptions {
fn default() -> Self {
Self {
quick: false,
preset: ThresholdPreset::Default,
complexity_threshold: 10,
cohesion_threshold: 2,
similarity_threshold: 0.7,
max_items: 50,
summary: false,
}
}
}
fn complexity_threshold_for(preset: ThresholdPreset) -> usize {
match preset {
ThresholdPreset::Strict => 8,
ThresholdPreset::Default => 10,
ThresholdPreset::Relaxed => 15,
}
}
fn cohesion_threshold_for(preset: ThresholdPreset) -> usize {
match preset {
ThresholdPreset::Strict => 1,
ThresholdPreset::Default => 2,
ThresholdPreset::Relaxed => 3,
}
}
fn similarity_threshold_for(preset: ThresholdPreset) -> f64 {
match preset {
ThresholdPreset::Strict => 0.6,
ThresholdPreset::Default => 0.7,
ThresholdPreset::Relaxed => 0.8,
}
}
impl HealthOptions {
pub fn with_preset(preset: ThresholdPreset) -> Self {
Self {
quick: false,
preset,
complexity_threshold: complexity_threshold_for(preset),
cohesion_threshold: cohesion_threshold_for(preset),
similarity_threshold: similarity_threshold_for(preset),
max_items: 50,
summary: false,
}
}
pub fn quick() -> Self {
Self {
quick: true,
..Default::default()
}
}
pub fn with_summary(mut self, summary: bool) -> Self {
self.summary = summary;
self
}
pub fn with_max_items(mut self, max_items: usize) -> Self {
self.max_items = max_items;
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct SubAnalysisResult {
pub name: String,
pub success: bool,
pub elapsed_ms: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
pub findings_count: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<serde_json::Value>,
}
impl SubAnalysisResult {
pub fn success(
name: impl Into<String>,
elapsed_ms: f64,
findings_count: usize,
details: serde_json::Value,
) -> Self {
Self {
name: name.into(),
success: true,
elapsed_ms,
error: None,
findings_count,
details: Some(details),
}
}
pub fn failure(name: impl Into<String>, elapsed_ms: f64, error: impl Into<String>) -> Self {
Self {
name: name.into(),
success: false,
elapsed_ms,
error: Some(error.into()),
findings_count: 0,
details: None,
}
}
pub fn skipped(name: impl Into<String>) -> Self {
Self {
name: name.into(),
success: false,
elapsed_ms: 0.0,
error: Some("skipped (quick mode)".to_string()),
findings_count: 0,
details: None,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct HealthSummary {
pub files_analyzed: usize,
pub functions_analyzed: usize,
pub classes_analyzed: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub avg_cyclomatic: Option<f64>,
pub hotspot_count: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub avg_lcom4: Option<f64>,
pub low_cohesion_count: usize,
pub dead_count: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub dead_percentage: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub avg_distance: Option<f64>,
pub packages_in_pain_zone: usize,
pub tight_coupling_pairs: usize,
pub similar_pairs: usize,
}
impl HealthSummary {
pub fn new() -> Self {
Self::default()
}
pub fn with_complexity(mut self, avg: Option<f64>, hotspots: usize) -> Self {
self.avg_cyclomatic = avg;
self.hotspot_count = hotspots;
self
}
pub fn with_cohesion(
mut self,
avg_lcom4: Option<f64>,
low_cohesion: usize,
classes: usize,
) -> Self {
self.avg_lcom4 = avg_lcom4;
self.low_cohesion_count = low_cohesion;
self.classes_analyzed = classes;
self
}
pub fn with_dead_code(mut self, dead: usize, total: usize) -> Self {
self.dead_count = dead;
if total > 0 {
self.dead_percentage = Some((dead as f64 / total as f64) * 100.0);
}
self.functions_analyzed = total;
self
}
pub fn with_martin(mut self, avg_distance: Option<f64>, pain_zone: usize) -> Self {
self.avg_distance = avg_distance;
self.packages_in_pain_zone = pain_zone;
self
}
pub fn with_coupling(mut self, tight_pairs: usize) -> Self {
self.tight_coupling_pairs = tight_pairs;
self
}
pub fn with_similarity(mut self, similar: usize) -> Self {
self.similar_pairs = similar;
self
}
}
fn serialize_path<S>(path: &Path, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&path.display().to_string())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct HealthReport {
pub wrapper: String,
#[serde(serialize_with = "serialize_path")]
pub path: PathBuf,
#[serde(skip_serializing_if = "Option::is_none")]
pub language: Option<Language>,
pub quick_mode: bool,
pub total_elapsed_ms: f64,
pub summary: HealthSummary,
#[serde(rename = "details")]
pub sub_results: IndexMap<String, SubAnalysisResult>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub errors: Vec<String>,
}
impl HealthReport {
pub fn new(path: PathBuf, language: Option<Language>, quick: bool) -> Self {
Self {
wrapper: "health".to_string(),
path,
language,
quick_mode: quick,
total_elapsed_ms: 0.0,
summary: HealthSummary::new(),
sub_results: IndexMap::new(),
errors: Vec::new(),
}
}
pub fn add_sub_result(&mut self, result: SubAnalysisResult) {
self.sub_results.insert(result.name.clone(), result);
}
pub fn with_error(mut self, error: String) -> Self {
self.errors.push(error);
self
}
pub fn with_elapsed(mut self, elapsed_ms: f64) -> Self {
self.total_elapsed_ms = elapsed_ms;
self
}
pub fn with_summary(mut self, summary: HealthSummary) -> Self {
self.summary = summary;
self
}
pub fn to_dict(&self) -> serde_json::Value {
serde_json::to_value(self).unwrap_or_default()
}
pub fn detail(&self, sub_name: &str) -> Option<&serde_json::Value> {
self.sub_results
.get(sub_name)
.and_then(|r| r.details.as_ref())
}
pub fn to_text(&self) -> String {
let mut output = String::new();
let path_str = self.path.display().to_string();
let truncated_path = if path_str.len() > 60 {
format!("...{}", &path_str[path_str.len() - 57..])
} else {
path_str
};
output.push_str(&format!("Health Report: {}\n", truncated_path));
output.push_str(&"=".repeat(50));
output.push('\n');
let cc_line = if let Some(avg) = self.summary.avg_cyclomatic {
format!(
"Complexity: avg CC={:.1}, hotspots={} (CC>10)\n",
avg, self.summary.hotspot_count
)
} else if let Some(result) = self.sub_results.get("complexity") {
if !result.success {
format!(
"Complexity: {}\n",
result.error.as_deref().unwrap_or("failed")
)
} else {
"Complexity: no data\n".to_string()
}
} else {
"Complexity: not analyzed\n".to_string()
};
output.push_str(&cc_line);
let coh_line = if let Some(avg) = self.summary.avg_lcom4 {
format!(
"Cohesion: {} classes, avg LCOM4={:.1}, {} low-cohesion\n",
self.summary.classes_analyzed, avg, self.summary.low_cohesion_count
)
} else if let Some(result) = self.sub_results.get("cohesion") {
if !result.success {
format!(
"Cohesion: {}\n",
result.error.as_deref().unwrap_or("failed")
)
} else {
"Cohesion: no data\n".to_string()
}
} else {
"Cohesion: not analyzed\n".to_string()
};
output.push_str(&coh_line);
if !self.quick_mode {
if self.summary.tight_coupling_pairs > 0 {
output.push_str(&format!(
"Coupling: {} tightly coupled pairs\n",
self.summary.tight_coupling_pairs
));
} else if let Some(result) = self.sub_results.get("coupling") {
if !result.success {
output.push_str(&format!(
"Coupling: {}\n",
result.error.as_deref().unwrap_or("failed")
));
} else {
output.push_str("Coupling: no tight coupling detected\n");
}
}
}
let dead_line = if self.summary.dead_count > 0 {
format!(
"Dead Code: {} unreachable functions\n",
self.summary.dead_count
)
} else if let Some(result) = self.sub_results.get("dead") {
if !result.success {
format!(
"Dead Code: {}\n",
result.error.as_deref().unwrap_or("failed")
)
} else {
"Dead Code: none detected\n".to_string()
}
} else {
"Dead Code: not analyzed\n".to_string()
};
output.push_str(&dead_line);
if !self.quick_mode {
if self.summary.similar_pairs > 0 {
output.push_str(&format!(
"Duplication: {} clone pairs detected\n",
self.summary.similar_pairs
));
} else if let Some(result) = self.sub_results.get("similar") {
if !result.success {
output.push_str(&format!(
"Duplication: {}\n",
result.error.as_deref().unwrap_or("failed")
));
} else {
output.push_str("Duplication: no clones detected\n");
}
}
}
let metrics_line = if let Some(avg_d) = self.summary.avg_distance {
format!(
"Metrics: avg D={:.2} (distance from main sequence)\n",
avg_d
)
} else if let Some(result) = self.sub_results.get("metrics") {
if !result.success {
format!(
"Metrics: {}\n",
result.error.as_deref().unwrap_or("failed")
)
} else {
"Metrics: no data\n".to_string()
}
} else {
"Metrics: not analyzed\n".to_string()
};
output.push_str(&metrics_line);
output.push('\n');
output.push_str(&format!("Elapsed: {:.0}ms\n", self.total_elapsed_ms));
if !self.errors.is_empty() {
output.push_str(&format!("\nErrors: {}\n", self.errors.join(", ")));
}
output
}
}
pub fn run_health(
path: &Path,
language: Option<Language>,
options: HealthOptions,
) -> TldrResult<HealthReport> {
let start = Instant::now();
if !path.exists() {
return Err(TldrError::PathNotFound(path.to_path_buf()));
}
let detected_language = match language {
Some(l) => l,
None => detect_language(path)?,
};
let mut report = HealthReport::new(path.to_path_buf(), Some(detected_language), options.quick);
let call_graph_result = build_project_call_graph(path, detected_language, None, true);
let (call_graph, call_graph_error) = match call_graph_result {
Ok(g) => (Some(g), None),
Err(e) => (None, Some(e.to_string())),
};
let module_infos = collect_module_infos(path, detected_language);
let complexity_result = run_with_timing("complexity", || {
let opts = ComplexityOptions {
hotspot_threshold: options.complexity_threshold,
..Default::default()
};
analyze_complexity(path, Some(detected_language), Some(opts))
});
report.add_sub_result(complexity_result);
let cohesion_result = run_with_timing("cohesion", || {
analyze_cohesion(path, Some(detected_language), options.cohesion_threshold)
});
report.add_sub_result(cohesion_result);
let dead_result = if !module_infos.is_empty() {
run_with_timing("dead", || {
analyze_dead_code_with_refcount(
path,
detected_language,
&module_infos,
&["main", "test_"],
)
})
} else {
SubAnalysisResult::failure("dead", 0.0, "No module infos collected")
};
report.add_sub_result(dead_result);
let martin_result = run_with_timing("metrics", || {
compute_martin_metrics(path, Some(detected_language))
});
report.add_sub_result(martin_result);
if options.quick {
report.add_sub_result(SubAnalysisResult::skipped("coupling"));
} else {
let coupling_result = if let Some(ref cg) = call_graph {
run_with_timing("coupling", || {
let opts = CouplingOptions {
max_pairs: options.max_items,
..Default::default()
};
analyze_coupling_with_graph(path, detected_language, cg, &opts)
})
} else {
let error_msg = call_graph_error
.clone()
.unwrap_or_else(|| "Call graph not available".to_string());
SubAnalysisResult::failure("coupling", 0.0, error_msg)
};
report.add_sub_result(coupling_result);
}
if options.quick {
report.add_sub_result(SubAnalysisResult::skipped("similar"));
} else {
let clones_start = Instant::now();
let clones_result = {
let opts = ClonesOptions {
max_clones: options.max_items,
exclude_tests: true,
..Default::default()
};
match detect_clones(path, &opts) {
Ok(report) => {
let elapsed_ms = clones_start.elapsed().as_secs_f64() * 1000.0;
let count = report.clone_pairs.len();
let details = serde_json::to_value(&report).ok();
SubAnalysisResult {
name: "similar".to_string(),
success: true,
elapsed_ms,
error: None,
findings_count: count,
details,
}
}
Err(e) => SubAnalysisResult {
name: "similar".to_string(),
success: false,
elapsed_ms: clones_start.elapsed().as_secs_f64() * 1000.0,
error: Some(e.to_string()),
findings_count: 0,
details: None,
},
}
};
report.add_sub_result(clones_result);
}
let summary = aggregate_summary(&report.sub_results);
report.summary = summary;
report.total_elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;
Ok(report)
}
fn run_with_timing<T, F>(name: &str, f: F) -> SubAnalysisResult
where
F: FnOnce() -> TldrResult<T>,
T: Serialize + AnalysisMetrics,
{
let start = Instant::now();
match f() {
Ok(result) => {
let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;
let findings_count = result.findings_count();
let details = serde_json::to_value(&result).ok();
SubAnalysisResult {
name: name.to_string(),
success: true,
elapsed_ms,
error: None,
findings_count,
details,
}
}
Err(e) => SubAnalysisResult {
name: name.to_string(),
success: false,
elapsed_ms: start.elapsed().as_secs_f64() * 1000.0,
error: Some(e.to_string()),
findings_count: 0,
details: None,
},
}
}
trait AnalysisMetrics {
fn findings_count(&self) -> usize;
}
impl AnalysisMetrics for ComplexityReport {
fn findings_count(&self) -> usize {
self.hotspot_count
}
}
impl AnalysisMetrics for CohesionReport {
fn findings_count(&self) -> usize {
self.low_cohesion_count
}
}
impl AnalysisMetrics for DeadCodeReport {
fn findings_count(&self) -> usize {
self.dead_count
}
}
impl AnalysisMetrics for MartinReport {
fn findings_count(&self) -> usize {
self.packages_in_pain_zone + self.packages_in_uselessness_zone
}
}
impl AnalysisMetrics for CouplingReport {
fn findings_count(&self) -> usize {
self.tight_coupling_count
}
}
fn detect_language(path: &Path) -> TldrResult<Language> {
if path.is_file() {
return Language::from_path(path).ok_or_else(|| {
TldrError::UnsupportedLanguage(
path.extension()
.and_then(|e| e.to_str())
.unwrap_or("unknown")
.to_string(),
)
});
}
let mut counts: HashMap<Language, usize> = HashMap::new();
for entry in WalkDir::new(path)
.max_depth(5)
.into_iter()
.filter_map(|e| e.ok())
{
if entry.file_type().is_file() {
if let Some(lang) = Language::from_path(entry.path()) {
*counts.entry(lang).or_default() += 1;
}
}
}
counts
.into_iter()
.max_by_key(|(_, count)| *count)
.map(|(lang, _)| lang)
.ok_or_else(|| TldrError::NoSupportedFiles(path.to_path_buf()))
}
fn collect_module_infos(path: &Path, language: Language) -> Vec<(PathBuf, ModuleInfo)> {
let mut module_infos: Vec<(PathBuf, ModuleInfo)> = Vec::new();
let extensions = language.extensions();
if path.is_file() {
if let Ok(info) = extract_file(path, path.parent()) {
module_infos.push((path.to_path_buf(), info));
}
} else {
for entry in WalkDir::new(path)
.follow_links(true)
.into_iter()
.filter_map(|e| e.ok())
{
let file_path = entry.path();
if file_path.is_file() {
if let Some(ext) = file_path.extension().and_then(|e| e.to_str()) {
let ext_with_dot = format!(".{}", ext);
if extensions.contains(&ext_with_dot.as_str()) {
if let Ok(info) = extract_file(file_path, Some(path)) {
module_infos.push((file_path.to_path_buf(), info));
}
}
}
}
}
}
module_infos
}
fn aggregate_summary(sub_results: &IndexMap<String, SubAnalysisResult>) -> HealthSummary {
let mut summary = HealthSummary::new();
if let Some(result) = sub_results.get("complexity") {
if result.success {
if let Some(ref details) = result.details {
if let Some(avg) = details.get("avg_cyclomatic").and_then(|v| v.as_f64()) {
summary.avg_cyclomatic = Some(avg);
}
if let Some(count) = details.get("hotspot_count").and_then(|v| v.as_u64()) {
summary.hotspot_count = count as usize;
}
if let Some(count) = details.get("functions_analyzed").and_then(|v| v.as_u64()) {
summary.functions_analyzed = count as usize;
}
}
}
}
if let Some(result) = sub_results.get("cohesion") {
if result.success {
if let Some(ref details) = result.details {
if let Some(avg) = details.get("avg_lcom4").and_then(|v| v.as_f64()) {
summary.avg_lcom4 = Some(avg);
}
if let Some(count) = details.get("low_cohesion_count").and_then(|v| v.as_u64()) {
summary.low_cohesion_count = count as usize;
}
if let Some(count) = details.get("classes_analyzed").and_then(|v| v.as_u64()) {
summary.classes_analyzed = count as usize;
}
}
}
}
if let Some(result) = sub_results.get("dead") {
if result.success {
if let Some(ref details) = result.details {
if let Some(count) = details.get("dead_count").and_then(|v| v.as_u64()) {
summary.dead_count = count as usize;
}
if let Some(pct) = details.get("dead_percentage").and_then(|v| v.as_f64()) {
summary.dead_percentage = Some(pct);
}
}
}
}
if let Some(result) = sub_results.get("metrics") {
if result.success {
if let Some(ref details) = result.details {
if let Some(avg) = details.get("avg_distance").and_then(|v| v.as_f64()) {
summary.avg_distance = Some(avg);
}
if let Some(count) = details
.get("packages_in_pain_zone")
.and_then(|v| v.as_u64())
{
summary.packages_in_pain_zone = count as usize;
}
}
}
}
if let Some(result) = sub_results.get("coupling") {
if result.success {
if let Some(ref details) = result.details {
if let Some(count) = details.get("tight_coupling_count").and_then(|v| v.as_u64()) {
summary.tight_coupling_pairs = count as usize;
}
}
}
}
if let Some(result) = sub_results.get("similar") {
if result.success {
summary.similar_pairs = result.findings_count;
}
}
summary
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_severity_ordering() {
assert!(Severity::Critical > Severity::High);
assert!(Severity::High > Severity::Medium);
assert!(Severity::Medium > Severity::Low);
assert!(Severity::Low > Severity::Info);
assert!(Severity::Critical.is_at_least(Severity::High));
assert!(Severity::High.is_at_least(Severity::High));
assert!(!Severity::Medium.is_at_least(Severity::High));
}
#[test]
fn test_severity_comparison() {
let severities = vec![
Severity::Medium,
Severity::Critical,
Severity::Low,
Severity::High,
Severity::Info,
];
let mut sorted = severities.clone();
sorted.sort();
assert_eq!(
sorted,
vec![
Severity::Info,
Severity::Low,
Severity::Medium,
Severity::High,
Severity::Critical,
]
);
}
#[test]
fn test_threshold_preset_defaults() {
assert_eq!(complexity_threshold_for(ThresholdPreset::Default), 10);
assert_eq!(cohesion_threshold_for(ThresholdPreset::Default), 2);
assert!((similarity_threshold_for(ThresholdPreset::Default) - 0.7).abs() < 0.001);
assert_eq!(complexity_threshold_for(ThresholdPreset::Strict), 8);
assert_eq!(cohesion_threshold_for(ThresholdPreset::Strict), 1);
assert_eq!(complexity_threshold_for(ThresholdPreset::Relaxed), 15);
assert_eq!(cohesion_threshold_for(ThresholdPreset::Relaxed), 3);
}
#[test]
fn test_health_report_structure() {
let report = HealthReport::new(PathBuf::from("src/"), Some(Language::Python), false);
assert_eq!(report.wrapper, "health");
assert_eq!(report.path, PathBuf::from("src/"));
assert_eq!(report.language, Some(Language::Python));
assert!(!report.quick_mode);
assert!(report.sub_results.is_empty());
assert!(report.errors.is_empty());
}
#[test]
fn test_health_summary_aggregation() {
let summary = HealthSummary::new()
.with_complexity(Some(5.5), 3)
.with_cohesion(Some(1.5), 2, 10)
.with_dead_code(5, 100)
.with_martin(Some(0.25), 1)
.with_coupling(2)
.with_similarity(4);
assert_eq!(summary.avg_cyclomatic, Some(5.5));
assert_eq!(summary.hotspot_count, 3);
assert_eq!(summary.avg_lcom4, Some(1.5));
assert_eq!(summary.low_cohesion_count, 2);
assert_eq!(summary.classes_analyzed, 10);
assert_eq!(summary.dead_count, 5);
assert!((summary.dead_percentage.unwrap() - 5.0).abs() < 0.001);
assert_eq!(summary.functions_analyzed, 100);
assert_eq!(summary.avg_distance, Some(0.25));
assert_eq!(summary.packages_in_pain_zone, 1);
assert_eq!(summary.tight_coupling_pairs, 2);
assert_eq!(summary.similar_pairs, 4);
}
#[test]
fn test_sub_analysis_result_structure() {
let success =
SubAnalysisResult::success("complexity", 150.5, 10, serde_json::json!({"avg": 5.5}));
assert!(success.success);
assert_eq!(success.name, "complexity");
assert!((success.elapsed_ms - 150.5).abs() < 0.001);
assert!(success.error.is_none());
assert_eq!(success.findings_count, 10);
assert!(success.details.is_some());
let failure = SubAnalysisResult::failure("cohesion", 50.0, "parse error");
assert!(!failure.success);
assert_eq!(failure.error, Some("parse error".to_string()));
assert!(failure.details.is_none());
let skipped = SubAnalysisResult::skipped("coupling");
assert!(!skipped.success);
assert!(skipped.error.as_ref().unwrap().contains("quick mode"));
assert_eq!(skipped.elapsed_ms, 0.0);
}
#[test]
fn test_health_report_add_sub_result() {
let mut report = HealthReport::new(PathBuf::from("src/"), None, false);
let complexity =
SubAnalysisResult::success("complexity", 100.0, 5, serde_json::json!({"hotspots": 2}));
report.add_sub_result(complexity);
assert_eq!(report.sub_results.len(), 1);
assert!(report.sub_results.contains_key("complexity"));
}
#[test]
fn test_health_report_to_text() {
let mut report = HealthReport::new(PathBuf::from("src/"), Some(Language::Python), false);
report.total_elapsed_ms = 1234.0;
report.summary = HealthSummary::new()
.with_complexity(Some(5.2), 3)
.with_cohesion(Some(1.5), 2, 12)
.with_dead_code(5, 50);
let text = report.to_text();
assert!(text.contains("Health Report: src/"));
assert!(text.contains("avg CC=5.2"));
assert!(text.contains("hotspots=3"));
assert!(text.contains("12 classes"));
assert!(text.contains("LCOM4=1.5"));
assert!(text.contains("5 unreachable"));
assert!(text.contains("1234ms"));
}
#[test]
fn test_health_report_detail_method() {
let mut report = HealthReport::new(PathBuf::from("src/"), None, false);
let complexity = SubAnalysisResult::success(
"complexity",
100.0,
5,
serde_json::json!({"hotspots": [{"name": "func1", "cc": 15}]}),
);
report.add_sub_result(complexity);
let detail = report.detail("complexity");
assert!(detail.is_some());
assert!(detail.unwrap()["hotspots"].is_array());
assert!(report.detail("nonexistent").is_none());
}
#[test]
fn test_health_options_default() {
let opts = HealthOptions::default();
assert!(!opts.quick);
assert_eq!(opts.preset, ThresholdPreset::Default);
assert_eq!(opts.complexity_threshold, 10);
assert_eq!(opts.cohesion_threshold, 2);
assert!((opts.similarity_threshold - 0.7).abs() < 0.001);
}
#[test]
fn test_health_options_with_preset() {
let strict = HealthOptions::with_preset(ThresholdPreset::Strict);
assert_eq!(strict.complexity_threshold, 8);
assert_eq!(strict.cohesion_threshold, 1);
assert!((strict.similarity_threshold - 0.6).abs() < 0.001);
}
#[test]
fn test_health_options_quick() {
let quick = HealthOptions::quick();
assert!(quick.quick);
}
#[test]
fn test_health_report_json_serialization() {
let mut report = HealthReport::new(PathBuf::from("test/"), Some(Language::Python), true);
report.summary = HealthSummary::new().with_complexity(Some(5.0), 2);
let json = report.to_dict();
assert_eq!(json["wrapper"], "health");
assert_eq!(json["path"], "test/");
assert_eq!(json["quick_mode"], true);
assert_eq!(json["summary"]["avg_cyclomatic"], 5.0);
assert_eq!(json["summary"]["hotspot_count"], 2);
assert!(json.get("details").is_some());
}
#[test]
fn test_indexmap_preserves_order() {
let mut report = HealthReport::new(PathBuf::from("test/"), None, false);
report.add_sub_result(SubAnalysisResult::skipped("complexity"));
report.add_sub_result(SubAnalysisResult::skipped("cohesion"));
report.add_sub_result(SubAnalysisResult::skipped("dead"));
report.add_sub_result(SubAnalysisResult::skipped("metrics"));
report.add_sub_result(SubAnalysisResult::skipped("coupling"));
report.add_sub_result(SubAnalysisResult::skipped("similar"));
let keys: Vec<_> = report.sub_results.keys().collect();
assert_eq!(
keys,
vec![
"complexity",
"cohesion",
"dead",
"metrics",
"coupling",
"similar"
]
);
}
}