use std::collections::HashMap;
use std::path::{Path, PathBuf};
use rayon::prelude::*;
use serde::{Deserialize, Serialize};
use tracing::debug;
use tree_sitter::Node;
use crate::ast::AstExtractor;
use crate::callgraph::scanner::{ProjectScanner, ScanConfig};
use crate::error::{Result, BrrrError};
use crate::lang::LanguageRegistry;
use super::halstead::HalsteadMetrics;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MaintainabilityRiskLevel {
Low,
Medium,
High,
Critical,
}
impl MaintainabilityRiskLevel {
#[must_use]
pub fn from_score(score: f64) -> Self {
match score {
s if s >= 50.0 => Self::Low,
s if s >= 20.0 => Self::Medium,
s if s >= 10.0 => Self::High,
_ => Self::Critical,
}
}
#[must_use]
pub const fn description(&self) -> &'static str {
match self {
Self::Low => "Highly maintainable",
Self::Medium => "Moderately maintainable",
Self::High => "Hard to maintain",
Self::Critical => "Very hard to maintain, refactor immediately",
}
}
#[must_use]
pub const fn color_code(&self) -> &'static str {
match self {
Self::Low => "\x1b[32m", Self::Medium => "\x1b[33m", Self::High => "\x1b[31m", Self::Critical => "\x1b[35m", }
}
}
impl std::fmt::Display for MaintainabilityRiskLevel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Low => write!(f, "low"),
Self::Medium => write!(f, "medium"),
Self::High => write!(f, "high"),
Self::Critical => write!(f, "critical"),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct LinesOfCode {
pub physical: u32,
pub source: u32,
pub logical: u32,
pub comment_lines: u32,
pub blank_lines: u32,
pub comment_only_lines: u32,
pub effective: u32,
}
impl LinesOfCode {
#[must_use]
pub fn comment_percentage(&self) -> f64 {
if self.physical == 0 {
return 0.0;
}
(f64::from(self.comment_lines) / f64::from(self.physical)) * 100.0
}
#[must_use]
pub fn comment_ratio(&self) -> f64 {
if self.source == 0 {
return 0.0;
}
(f64::from(self.comment_lines) / f64::from(self.source)) * 100.0
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MaintainabilityIndex {
pub score: f64,
pub risk_level: MaintainabilityRiskLevel,
pub halstead_volume: f64,
pub cyclomatic_complexity: u32,
pub lines_of_code: LinesOfCode,
pub comment_percentage: f64,
pub includes_comments: bool,
}
impl MaintainabilityIndex {
#[must_use]
pub fn calculate(
halstead_volume: f64,
cyclomatic_complexity: u32,
loc: LinesOfCode,
) -> Self {
let effective_loc = loc.effective.max(1); let volume = halstead_volume.max(1.0); let cc = f64::from(cyclomatic_complexity);
let raw_mi = 171.0
- 5.2 * volume.ln()
- 0.23 * cc
- 16.2 * f64::from(effective_loc).ln();
let score = (raw_mi * 100.0 / 171.0).max(0.0).min(100.0);
let comment_percentage = loc.comment_percentage();
Self {
score,
risk_level: MaintainabilityRiskLevel::from_score(score),
halstead_volume,
cyclomatic_complexity,
lines_of_code: loc,
comment_percentage,
includes_comments: false,
}
}
#[must_use]
pub fn calculate_with_comments(
halstead_volume: f64,
cyclomatic_complexity: u32,
loc: LinesOfCode,
) -> Self {
let effective_loc = loc.effective.max(1);
let volume = halstead_volume.max(1.0);
let cc = f64::from(cyclomatic_complexity);
let cm = loc.comment_percentage();
let comment_factor = 50.0 * (2.4 * cm).sqrt().sin();
let raw_mi = 171.0
- 5.2 * volume.ln()
- 0.23 * cc
- 16.2 * f64::from(effective_loc).ln()
+ comment_factor;
let score = (raw_mi * 100.0 / 171.0).max(0.0).min(100.0);
Self {
score,
risk_level: MaintainabilityRiskLevel::from_score(score),
halstead_volume,
cyclomatic_complexity,
lines_of_code: loc,
comment_percentage: cm,
includes_comments: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FunctionMaintainability {
pub function_name: String,
pub file: PathBuf,
pub line: usize,
pub end_line: usize,
pub index: MaintainabilityIndex,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MaintainabilityStats {
pub total_functions: usize,
pub average_mi: f64,
pub min_mi: f64,
pub max_mi: f64,
pub median_mi: f64,
pub risk_distribution: HashMap<String, usize>,
pub total_sloc: u32,
pub total_comment_lines: u32,
pub overall_comment_percentage: f64,
pub average_volume: f64,
pub average_cc: f64,
}
impl MaintainabilityStats {
fn from_functions(functions: &[FunctionMaintainability]) -> Self {
if functions.is_empty() {
return Self {
total_functions: 0,
average_mi: 0.0,
min_mi: 0.0,
max_mi: 0.0,
median_mi: 0.0,
risk_distribution: HashMap::new(),
total_sloc: 0,
total_comment_lines: 0,
overall_comment_percentage: 0.0,
average_volume: 0.0,
average_cc: 0.0,
};
}
let total = functions.len();
let scores: Vec<f64> = functions.iter().map(|f| f.index.score).collect();
let volumes: Vec<f64> = functions.iter().map(|f| f.index.halstead_volume).collect();
let ccs: Vec<u32> = functions.iter().map(|f| f.index.cyclomatic_complexity).collect();
let sum_mi: f64 = scores.iter().sum();
let average_mi = sum_mi / total as f64;
let min_mi = scores.iter().cloned().fold(f64::INFINITY, f64::min);
let max_mi = scores.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let mut sorted_scores = scores.clone();
sorted_scores.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let median_mi = if total % 2 == 0 {
(sorted_scores[total / 2 - 1] + sorted_scores[total / 2]) / 2.0
} else {
sorted_scores[total / 2]
};
let mut risk_distribution = HashMap::new();
for func in functions {
let risk = func.index.risk_level.to_string();
*risk_distribution.entry(risk).or_insert(0) += 1;
}
let total_sloc: u32 = functions
.iter()
.map(|f| f.index.lines_of_code.source)
.sum();
let total_comment_lines: u32 = functions
.iter()
.map(|f| f.index.lines_of_code.comment_lines)
.sum();
let total_physical: u32 = functions
.iter()
.map(|f| f.index.lines_of_code.physical)
.sum();
let overall_comment_percentage = if total_physical > 0 {
(f64::from(total_comment_lines) / f64::from(total_physical)) * 100.0
} else {
0.0
};
let average_volume = volumes.iter().sum::<f64>() / total as f64;
let average_cc = ccs.iter().map(|&c| f64::from(c)).sum::<f64>() / total as f64;
Self {
total_functions: total,
average_mi,
min_mi,
max_mi,
median_mi,
risk_distribution,
total_sloc,
total_comment_lines,
overall_comment_percentage,
average_volume,
average_cc,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MaintainabilityAnalysis {
pub path: PathBuf,
pub language: Option<String>,
pub functions: Vec<FunctionMaintainability>,
#[serde(skip_serializing_if = "Option::is_none")]
pub violations: Option<Vec<FunctionMaintainability>>,
pub stats: MaintainabilityStats,
#[serde(skip_serializing_if = "Option::is_none")]
pub threshold: Option<f64>,
pub includes_comments: bool,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub errors: Vec<MaintainabilityError>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MaintainabilityError {
pub file: PathBuf,
pub message: String,
}
fn calculate_loc(source: &str, language: &str) -> LinesOfCode {
let lines: Vec<&str> = source.lines().collect();
let physical = lines.len() as u32;
let mut blank_lines = 0u32;
let mut comment_lines = 0u32;
let mut comment_only_lines = 0u32;
let mut in_multiline_comment = false;
let mut in_python_docstring = false;
let mut docstring_char: char = '"';
let (line_comment, block_start, block_end) = get_comment_markers(language);
for line in &lines {
let trimmed = line.trim();
if trimmed.is_empty() {
blank_lines += 1;
continue;
}
if language == "python" {
if !in_python_docstring {
if trimmed.starts_with("\"\"\"") || trimmed.starts_with("'''") {
docstring_char = trimmed.chars().next().unwrap_or('"');
let end_marker: String = std::iter::repeat(docstring_char).take(3).collect();
comment_lines += 1;
let after_start = &trimmed[3..];
if after_start.contains(&end_marker) {
comment_only_lines += 1;
} else {
in_python_docstring = true;
comment_only_lines += 1;
}
continue;
}
} else {
let end_marker: String = std::iter::repeat(docstring_char).take(3).collect();
comment_lines += 1;
comment_only_lines += 1;
if trimmed.contains(&end_marker) {
in_python_docstring = false;
}
continue;
}
}
if in_multiline_comment {
comment_lines += 1;
if let Some(end) = block_end {
if trimmed.contains(end) {
in_multiline_comment = false;
if let Some(idx) = trimmed.find(end) {
let after = trimmed[idx + end.len()..].trim();
if after.is_empty() {
comment_only_lines += 1;
}
}
} else {
comment_only_lines += 1;
}
}
continue;
}
if let Some(start) = block_start {
if trimmed.contains(start) {
comment_lines += 1;
if let Some(end) = block_end {
if let Some(start_idx) = trimmed.find(start) {
let after_start = &trimmed[start_idx + start.len()..];
if after_start.contains(end) {
let before = &trimmed[..start_idx];
if let Some(end_idx) = after_start.find(end) {
let after = &after_start[end_idx + end.len()..];
if before.trim().is_empty() && after.trim().is_empty() {
comment_only_lines += 1;
}
}
} else {
in_multiline_comment = true;
let before = &trimmed[..start_idx];
if before.trim().is_empty() {
comment_only_lines += 1;
}
}
}
}
continue;
}
}
if let Some(marker) = line_comment {
if trimmed.starts_with(marker) {
comment_lines += 1;
comment_only_lines += 1;
continue;
}
if trimmed.contains(marker) {
comment_lines += 1;
}
}
}
let source = physical - blank_lines;
let effective = source - comment_only_lines;
let logical = estimate_logical_loc(source, language);
LinesOfCode {
physical,
source,
logical,
comment_lines,
blank_lines,
comment_only_lines,
effective,
}
}
fn get_comment_markers(language: &str) -> (Option<&'static str>, Option<&'static str>, Option<&'static str>) {
match language.to_lowercase().as_str() {
"python" => (Some("#"), None, None),
"typescript" | "javascript" | "tsx" | "jsx" | "rust" | "go" | "java" | "c" | "cpp" | "c++" => {
(Some("//"), Some("/*"), Some("*/"))
}
_ => (Some("#"), None, None), }
}
fn estimate_logical_loc(source_lines: u32, language: &str) -> u32 {
match language.to_lowercase().as_str() {
"python" => source_lines,
_ => source_lines,
}
}
fn calculate_function_loc(source: &str, start_line: usize, end_line: usize, language: &str) -> LinesOfCode {
let lines: Vec<&str> = source.lines().collect();
let start = start_line.saturating_sub(1);
let end = end_line.min(lines.len());
if start >= lines.len() || start >= end {
return LinesOfCode::default();
}
let function_source: String = lines[start..end].join("\n");
calculate_loc(&function_source, language)
}
pub fn analyze_maintainability(
path: impl AsRef<Path>,
language: Option<&str>,
threshold: Option<f64>,
include_comments: bool,
) -> Result<MaintainabilityAnalysis> {
let path = path.as_ref();
if !path.exists() {
return Err(BrrrError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("Path not found: {}", path.display()),
)));
}
if path.is_file() {
return analyze_file_maintainability(path, threshold, include_comments);
}
let path_str = path.to_str().ok_or_else(|| {
BrrrError::InvalidArgument("Invalid path encoding".to_string())
})?;
let scanner = ProjectScanner::new(path_str)?;
let config = if let Some(lang) = language {
ScanConfig::for_language(lang)
} else {
ScanConfig::default()
};
let scan_result = scanner.scan_with_config(&config)?;
if scan_result.files.is_empty() {
return Err(BrrrError::InvalidArgument(format!(
"No source files found in {} (filter: {:?})",
path.display(),
language
)));
}
debug!(
"Analyzing {} files for maintainability",
scan_result.files.len()
);
let results: Vec<(Vec<FunctionMaintainability>, Vec<MaintainabilityError>)> = scan_result
.files
.par_iter()
.map(|file| analyze_file_functions_maintainability(file, include_comments))
.collect();
let mut all_functions = Vec::new();
let mut all_errors = Vec::new();
for (functions, errors) in results {
all_functions.extend(functions);
all_errors.extend(errors);
}
let stats = MaintainabilityStats::from_functions(&all_functions);
let violations = threshold.map(|t| {
all_functions
.iter()
.filter(|f| f.index.score < t)
.cloned()
.collect::<Vec<_>>()
});
Ok(MaintainabilityAnalysis {
path: path.to_path_buf(),
language: language.map(String::from),
functions: all_functions,
violations,
stats,
threshold,
includes_comments: include_comments,
errors: all_errors,
})
}
pub fn analyze_file_maintainability(
file: impl AsRef<Path>,
threshold: Option<f64>,
include_comments: bool,
) -> Result<MaintainabilityAnalysis> {
let file = file.as_ref();
if !file.exists() {
return Err(BrrrError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("File not found: {}", file.display()),
)));
}
if !file.is_file() {
return Err(BrrrError::InvalidArgument(format!(
"Expected a file, got directory: {}",
file.display()
)));
}
let (functions, errors) = analyze_file_functions_maintainability(file, include_comments);
let stats = MaintainabilityStats::from_functions(&functions);
let registry = LanguageRegistry::global();
let language = registry
.detect_language(file)
.map(|l| l.name().to_string());
let violations = threshold.map(|t| {
functions
.iter()
.filter(|f| f.index.score < t)
.cloned()
.collect::<Vec<_>>()
});
Ok(MaintainabilityAnalysis {
path: file.to_path_buf(),
language,
functions,
violations,
stats,
threshold,
includes_comments: include_comments,
errors,
})
}
fn analyze_file_functions_maintainability(
file: &Path,
include_comments: bool,
) -> (Vec<FunctionMaintainability>, Vec<MaintainabilityError>) {
let mut results = Vec::new();
let mut errors = Vec::new();
let source = match std::fs::read_to_string(file) {
Ok(s) => s,
Err(e) => {
errors.push(MaintainabilityError {
file: file.to_path_buf(),
message: format!("Failed to read file: {}", e),
});
return (results, errors);
}
};
let registry = LanguageRegistry::global();
let lang = match registry.detect_language(file) {
Some(l) => l,
None => {
errors.push(MaintainabilityError {
file: file.to_path_buf(),
message: "Unsupported language".to_string(),
});
return (results, errors);
}
};
let lang_name = lang.name();
let mut parser = match lang.parser() {
Ok(p) => p,
Err(e) => {
errors.push(MaintainabilityError {
file: file.to_path_buf(),
message: format!("Failed to create parser: {}", e),
});
return (results, errors);
}
};
let tree = match parser.parse(&source, None) {
Some(t) => t,
None => {
errors.push(MaintainabilityError {
file: file.to_path_buf(),
message: "Failed to parse file".to_string(),
});
return (results, errors);
}
};
let module = match AstExtractor::extract_file(file) {
Ok(m) => m,
Err(e) => {
errors.push(MaintainabilityError {
file: file.to_path_buf(),
message: format!("Failed to extract AST: {}", e),
});
return (results, errors);
}
};
for func in &module.functions {
let start_line = func.line_number;
let end_line = func.end_line_number.unwrap_or(start_line);
match analyze_function_maintainability(
file,
&source,
&tree,
&func.name,
start_line,
end_line,
lang_name,
include_comments,
) {
Ok(mi) => results.push(mi),
Err(e) => {
debug!("Failed to analyze function {}: {}", func.name, e);
errors.push(MaintainabilityError {
file: file.to_path_buf(),
message: format!("Failed to analyze {}: {}", func.name, e),
});
}
}
}
for class in &module.classes {
for method in &class.methods {
let qualified_name = format!("{}.{}", class.name, method.name);
let start_line = method.line_number;
let end_line = method.end_line_number.unwrap_or(start_line);
match analyze_function_maintainability(
file,
&source,
&tree,
&qualified_name,
start_line,
end_line,
lang_name,
include_comments,
) {
Ok(mi) => results.push(mi),
Err(e) => {
debug!("Failed to analyze method {}: {}", qualified_name, e);
errors.push(MaintainabilityError {
file: file.to_path_buf(),
message: format!("Failed to analyze {}: {}", qualified_name, e),
});
}
}
}
}
(results, errors)
}
fn analyze_function_maintainability(
file: &Path,
source: &str,
tree: &tree_sitter::Tree,
function_name: &str,
start_line: usize,
end_line: usize,
language: &str,
include_comments: bool,
) -> Result<FunctionMaintainability> {
let loc = calculate_function_loc(source, start_line, end_line, language);
let function_node = find_function_node(tree, start_line, end_line);
let node_to_analyze = function_node.unwrap_or_else(|| tree.root_node());
let halstead = calculate_halstead_for_node(node_to_analyze, source.as_bytes(), language);
let cc = calculate_cyclomatic_for_node(node_to_analyze, language);
let index = if include_comments {
MaintainabilityIndex::calculate_with_comments(halstead.volume, cc, loc)
} else {
MaintainabilityIndex::calculate(halstead.volume, cc, loc)
};
Ok(FunctionMaintainability {
function_name: function_name.to_string(),
file: file.to_path_buf(),
line: start_line,
end_line,
index,
})
}
fn find_function_node(tree: &tree_sitter::Tree, start_line: usize, end_line: usize) -> Option<Node<'_>> {
let root = tree.root_node();
find_function_node_recursive(root, start_line, end_line)
}
fn find_function_node_recursive(
node: Node<'_>,
start_line: usize,
end_line: usize,
) -> Option<Node<'_>> {
let node_start = node.start_position().row + 1;
let node_end = node.end_position().row + 1;
if is_function_node(&node) && node_start == start_line && node_end >= end_line {
return Some(node);
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if let Some(found) = find_function_node_recursive(child, start_line, end_line) {
return Some(found);
}
}
None
}
fn is_function_node(node: &Node) -> bool {
matches!(
node.kind(),
"function_definition"
| "function_declaration"
| "method_definition"
| "function_item"
| "function"
| "method"
| "arrow_function"
| "function_expression"
| "method_declaration"
)
}
fn calculate_halstead_for_node(node: Node, source: &[u8], language: &str) -> HalsteadMetrics {
let mut operators = std::collections::HashSet::new();
let mut operands = std::collections::HashSet::new();
let mut total_operators = 0u32;
let mut total_operands = 0u32;
collect_tokens_recursive(
node,
source,
language,
&mut operators,
&mut operands,
&mut total_operators,
&mut total_operands,
);
HalsteadMetrics::from_counts(
operators.len() as u32,
operands.len() as u32,
total_operators,
total_operands,
)
}
fn collect_tokens_recursive(
node: Node,
source: &[u8],
language: &str,
operators: &mut std::collections::HashSet<String>,
operands: &mut std::collections::HashSet<String>,
total_operators: &mut u32,
total_operands: &mut u32,
) {
let kind = node.kind();
let text = node.utf8_text(source).unwrap_or("");
if is_operator_kind(kind) || is_operator_text(text, language) {
if !text.trim().is_empty() {
operators.insert(text.to_string());
*total_operators += 1;
}
} else if is_operand_kind(kind) {
if !text.trim().is_empty() {
operands.insert(text.to_string());
*total_operands += 1;
}
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
collect_tokens_recursive(
child,
source,
language,
operators,
operands,
total_operators,
total_operands,
);
}
}
fn is_operator_kind(kind: &str) -> bool {
matches!(
kind,
"binary_operator"
| "unary_operator"
| "comparison_operator"
| "boolean_operator"
| "augmented_assignment"
| "assignment"
| "assignment_expression"
| "+"
| "-"
| "*"
| "/"
| "%"
| "**"
| "//"
| "=="
| "!="
| "<"
| ">"
| "<="
| ">="
| "&&"
| "||"
| "!"
| "?"
| "("
| ")"
| "["
| "]"
| "{"
| "}"
| "."
| ","
| ";"
| ":"
| "->"
| "=>"
| "::"
)
}
fn is_operator_text(text: &str, language: &str) -> bool {
let common_ops = [
"+", "-", "*", "/", "%", "==", "!=", "<", ">", "<=", ">=",
"=", "+=", "-=", "*=", "/=", "&", "|", "^", "~", "<<", ">>",
"&&", "||", "!", ".", ",", ";", "(", ")", "[", "]", "{", "}",
"?", ":",
];
if common_ops.contains(&text) {
return true;
}
match language.to_lowercase().as_str() {
"python" => {
matches!(
text,
"and" | "or" | "not" | "in" | "is" | "**" | "//"
| "if" | "else" | "elif" | "while" | "for"
| "try" | "except" | "finally" | "raise"
| "def" | "class" | "return" | "yield"
| "lambda" | "with" | "as"
)
}
"typescript" | "javascript" | "tsx" | "jsx" => {
matches!(
text,
"=>" | "?." | "??" | "..." | "++" | "--"
| "if" | "else" | "while" | "for"
| "switch" | "case" | "default"
| "function" | "return" | "new"
| "typeof" | "instanceof"
)
}
"rust" => {
matches!(
text,
"::" | "->" | "=>" | ".." | "..="
| "if" | "else" | "while" | "for" | "loop"
| "match" | "return" | "fn" | "let" | "mut"
)
}
"go" => {
matches!(
text,
":=" | "<-" | "..."
| "if" | "else" | "for" | "switch" | "case"
| "func" | "return" | "go" | "defer"
)
}
_ => false,
}
}
fn is_operand_kind(kind: &str) -> bool {
matches!(
kind,
"identifier"
| "field_identifier"
| "property_identifier"
| "type_identifier"
| "number"
| "integer"
| "float"
| "string"
| "string_literal"
| "char_literal"
| "boolean"
| "true"
| "false"
| "none"
| "null"
| "nil"
| "self"
| "this"
)
}
fn calculate_cyclomatic_for_node(node: Node, language: &str) -> u32 {
let mut decision_points = 0u32;
count_decision_points_recursive(node, language, &mut decision_points);
decision_points + 1 }
fn count_decision_points_recursive(node: Node, language: &str, count: &mut u32) {
let kind = node.kind();
if is_decision_point(kind, language) {
*count += 1;
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
count_decision_points_recursive(child, language, count);
}
}
fn is_decision_point(kind: &str, language: &str) -> bool {
let common = matches!(
kind,
"if_statement"
| "if_expression"
| "elif_clause"
| "else_if_clause"
| "while_statement"
| "while_expression"
| "for_statement"
| "for_expression"
| "for_in_statement"
| "catch_clause"
| "except_clause"
| "case_clause"
| "match_arm"
| "conditional_expression"
| "ternary_expression"
);
if common {
return true;
}
match language.to_lowercase().as_str() {
"python" => {
matches!(
kind,
"list_comprehension"
| "dictionary_comprehension"
| "set_comprehension"
| "generator_expression"
| "boolean_operator"
)
}
"rust" => {
matches!(
kind,
"if_let_expression"
| "while_let_expression"
| "match_expression"
)
}
"go" => {
matches!(
kind,
"select_statement" | "communication_case" | "default_case"
)
}
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
fn create_temp_file(content: &str, extension: &str) -> NamedTempFile {
let mut file = tempfile::Builder::new()
.suffix(extension)
.tempfile()
.expect("Failed to create temp file");
file.write_all(content.as_bytes())
.expect("Failed to write to temp file");
file
}
#[test]
fn test_risk_level_classification() {
assert_eq!(
MaintainabilityRiskLevel::from_score(100.0),
MaintainabilityRiskLevel::Low
);
assert_eq!(
MaintainabilityRiskLevel::from_score(50.0),
MaintainabilityRiskLevel::Low
);
assert_eq!(
MaintainabilityRiskLevel::from_score(49.0),
MaintainabilityRiskLevel::Medium
);
assert_eq!(
MaintainabilityRiskLevel::from_score(20.0),
MaintainabilityRiskLevel::Medium
);
assert_eq!(
MaintainabilityRiskLevel::from_score(19.0),
MaintainabilityRiskLevel::High
);
assert_eq!(
MaintainabilityRiskLevel::from_score(10.0),
MaintainabilityRiskLevel::High
);
assert_eq!(
MaintainabilityRiskLevel::from_score(9.0),
MaintainabilityRiskLevel::Critical
);
assert_eq!(
MaintainabilityRiskLevel::from_score(0.0),
MaintainabilityRiskLevel::Critical
);
}
#[test]
fn test_loc_calculation_python() {
let source = r#"
# This is a comment
def hello():
"""Docstring"""
# Another comment
return "hello"
def world():
return "world"
"#;
let loc = calculate_loc(source, "python");
assert!(loc.physical > 0);
assert!(loc.comment_lines > 0);
assert!(loc.blank_lines > 0);
assert!(loc.source <= loc.physical);
assert!(loc.effective <= loc.source);
}
#[test]
fn test_loc_calculation_typescript() {
let source = r#"
// Single line comment
function greet(name: string): string {
/* Multi-line
comment */
return `Hello, ${name}!`;
}
"#;
let loc = calculate_loc(source, "typescript");
assert!(loc.physical > 0);
assert!(loc.comment_lines >= 2); assert!(loc.source > 0);
}
#[test]
fn test_mi_calculation_simple() {
let loc = LinesOfCode {
physical: 5,
source: 4,
logical: 4,
comment_lines: 1,
blank_lines: 1,
comment_only_lines: 1,
effective: 3,
};
let mi = MaintainabilityIndex::calculate(50.0, 1, loc);
assert!(mi.score > 50.0, "Simple function should have MI > 50, got {}", mi.score);
assert_eq!(mi.risk_level, MaintainabilityRiskLevel::Low);
}
#[test]
fn test_mi_calculation_complex() {
let loc = LinesOfCode {
physical: 200,
source: 180,
logical: 180,
comment_lines: 10,
blank_lines: 20,
comment_only_lines: 5,
effective: 175,
};
let mi = MaintainabilityIndex::calculate(5000.0, 30, loc);
assert!(mi.score < 50.0, "Complex function should have MI < 50, got {}", mi.score);
assert!(matches!(
mi.risk_level,
MaintainabilityRiskLevel::Medium
| MaintainabilityRiskLevel::High
| MaintainabilityRiskLevel::Critical
));
}
#[test]
fn test_mi_with_comments_bonus() {
let loc = LinesOfCode {
physical: 20,
source: 15,
logical: 15,
comment_lines: 10,
blank_lines: 5,
comment_only_lines: 5,
effective: 10,
};
let mi_without = MaintainabilityIndex::calculate(100.0, 5, loc.clone());
let mi_with = MaintainabilityIndex::calculate_with_comments(100.0, 5, loc);
assert!(mi_without.score >= 0.0 && mi_without.score <= 100.0);
assert!(mi_with.score >= 0.0 && mi_with.score <= 100.0);
assert!(!mi_without.includes_comments);
assert!(mi_with.includes_comments);
}
#[test]
fn test_comment_percentage() {
let loc = LinesOfCode {
physical: 100,
source: 80,
logical: 80,
comment_lines: 20,
blank_lines: 20,
comment_only_lines: 15,
effective: 65,
};
let pct = loc.comment_percentage();
assert!((pct - 20.0).abs() < 0.1, "Expected 20%, got {}%", pct);
}
#[test]
fn test_simple_python_maintainability() {
let source = r#"
def add(a, b):
return a + b
"#;
let file = create_temp_file(source, ".py");
let result = analyze_file_maintainability(file.path(), None, false);
assert!(result.is_ok());
let analysis = result.unwrap();
assert_eq!(analysis.functions.len(), 1);
let func = &analysis.functions[0];
assert_eq!(func.function_name, "add");
assert!(func.index.score > 0.0);
assert!(matches!(
func.index.risk_level,
MaintainabilityRiskLevel::Low | MaintainabilityRiskLevel::Medium
));
}
#[test]
fn test_complex_python_maintainability() {
let source = r#"
def process_data(items, threshold, callback, config):
result = []
errors = []
for item in items:
if item.value > threshold:
if item.is_valid():
try:
processed = callback(item, config)
if processed.status == "ok":
result.append(processed)
elif processed.status == "warning":
result.append(processed)
errors.append({"type": "warning", "item": item})
else:
errors.append({"type": "error", "item": item})
except ValueError as e:
errors.append({"type": "exception", "error": str(e)})
except TypeError as e:
errors.append({"type": "type_error", "error": str(e)})
else:
errors.append({"type": "invalid", "item": item})
elif item.value == threshold:
result.append(item)
return {"result": result, "errors": errors}
"#;
let file = create_temp_file(source, ".py");
let result = analyze_file_maintainability(file.path(), None, false);
assert!(result.is_ok());
let analysis = result.unwrap();
assert_eq!(analysis.functions.len(), 1);
let func = &analysis.functions[0];
assert!(
func.index.score < 80.0,
"Complex function should have MI < 80, got {}",
func.index.score
);
assert!(func.index.cyclomatic_complexity > 5);
}
#[test]
fn test_typescript_maintainability() {
let source = r#"
function greet(name: string): string {
return `Hello, ${name}!`;
}
function complexGreet(name: string, config: Config): string {
if (!name) {
return "Hello, stranger!";
}
if (config.formal) {
return `Good day, ${config.title} ${name}.`;
} else if (config.casual) {
return `Hey ${name}!`;
}
return `Hello, ${name}!`;
}
"#;
let file = create_temp_file(source, ".ts");
let result = analyze_file_maintainability(file.path(), None, false);
assert!(result.is_ok());
let analysis = result.unwrap();
assert!(analysis.functions.len() >= 1);
}
#[test]
fn test_threshold_filtering() {
let source = r#"
def simple():
return 1
def somewhat_complex(a, b, c):
if a > 0:
if b > 0:
if c > 0:
return a + b + c
return a + b
return a
return 0
"#;
let file = create_temp_file(source, ".py");
let result = analyze_file_maintainability(file.path(), Some(80.0), false);
assert!(result.is_ok());
let analysis = result.unwrap();
assert!(analysis.violations.is_some());
let violations = analysis.violations.unwrap();
assert!(!violations.is_empty() || analysis.functions.iter().all(|f| f.index.score >= 80.0));
}
#[test]
fn test_statistics_calculation() {
let source = r#"
def func1():
return 1
def func2(a, b):
if a > b:
return a
return b
def func3(items):
result = []
for item in items:
if item > 0:
result.append(item)
return result
"#;
let file = create_temp_file(source, ".py");
let result = analyze_file_maintainability(file.path(), None, false);
assert!(result.is_ok());
let analysis = result.unwrap();
assert_eq!(analysis.stats.total_functions, 3);
assert!(analysis.stats.average_mi > 0.0);
assert!(analysis.stats.min_mi <= analysis.stats.average_mi);
assert!(analysis.stats.max_mi >= analysis.stats.average_mi);
}
#[test]
fn test_empty_file() {
let source = "# Just a comment\n";
let file = create_temp_file(source, ".py");
let result = analyze_file_maintainability(file.path(), None, false);
assert!(result.is_ok());
let analysis = result.unwrap();
assert_eq!(analysis.functions.len(), 0);
assert_eq!(analysis.stats.total_functions, 0);
}
#[test]
fn test_nonexistent_file() {
let result = analyze_file_maintainability("/nonexistent/path/file.py", None, false);
assert!(result.is_err());
}
#[test]
fn test_class_methods_maintainability() {
let source = r#"
class Calculator:
def add(self, a, b):
return a + b
def complex_operation(self, items, threshold):
result = 0
for item in items:
if item.value > threshold:
if item.is_valid():
result += item.value
else:
result -= 1
elif item.value == threshold:
result += item.value // 2
return result
"#;
let file = create_temp_file(source, ".py");
let result = analyze_file_maintainability(file.path(), None, false);
assert!(result.is_ok());
let analysis = result.unwrap();
assert_eq!(analysis.functions.len(), 2);
let add = analysis.functions.iter().find(|f| f.function_name == "Calculator.add");
let complex = analysis
.functions
.iter()
.find(|f| f.function_name == "Calculator.complex_operation");
assert!(add.is_some());
assert!(complex.is_some());
assert!(
add.unwrap().index.score > complex.unwrap().index.score,
"Simple method should have higher MI"
);
}
#[test]
fn test_risk_distribution() {
let source = r#"
def func1():
return 1
def func2(a, b):
return a + b
def func3(x):
if x > 0:
return x
return -x
"#;
let file = create_temp_file(source, ".py");
let result = analyze_file_maintainability(file.path(), None, false);
assert!(result.is_ok());
let analysis = result.unwrap();
let total_in_dist: usize = analysis.stats.risk_distribution.values().sum();
assert_eq!(total_in_dist, analysis.stats.total_functions);
}
#[test]
fn test_mi_formula_edge_cases() {
let loc = LinesOfCode {
physical: 1,
source: 1,
logical: 1,
comment_lines: 0,
blank_lines: 0,
comment_only_lines: 0,
effective: 1,
};
let mi = MaintainabilityIndex::calculate(1.0, 1, loc);
assert!(mi.score >= 0.0 && mi.score <= 100.0);
let loc_zero = LinesOfCode::default();
let mi_zero = MaintainabilityIndex::calculate(0.0, 0, loc_zero);
assert!(mi_zero.score >= 0.0 && mi_zero.score <= 100.0);
}
#[test]
fn test_loc_multiline_comments() {
let source = r#"
function test() {
/* This is a
multi-line
comment */
return 42;
}
"#;
let loc = calculate_loc(source, "typescript");
assert!(loc.comment_lines >= 3, "Should count all multi-line comment lines");
}
}