#[cfg_attr(coverage_nightly, coverage(off))]
#[cfg(test)]
mod tests {
use serial_test::serial;
use std::fs;
use std::path::Path;
use std::process::Command;
use tempfile::TempDir;
#[test]
#[ignore = "requires dead code analyzer setup"]
fn test_cargo_reports_zero_dead_code_for_used_functions() {
let temp_dir = TempDir::new().unwrap();
let project_path = temp_dir.path();
create_minimal_rust_project(
project_path,
r#"
pub fn used_function() -> i32 {
42
}
pub fn main() {
let _ = used_function();
}
"#,
);
let dead_code_count = get_cargo_dead_code_warnings(project_path);
assert_eq!(
dead_code_count, 0,
"Cargo should report 0 dead code warnings for fully used code"
);
}
#[test]
#[serial] fn test_cargo_detects_unused_private_function() {
let temp_dir = TempDir::new().unwrap();
let project_path = temp_dir.path();
create_minimal_rust_project(
project_path,
r#"
fn unused_function() -> i32 {
42
}
pub fn main() {
println!("Hello");
}
"#,
);
let dead_code_count = get_cargo_dead_code_warnings(project_path);
assert_eq!(
dead_code_count, 1,
"Cargo should detect 1 unused private function"
);
}
#[test]
fn test_dead_code_percentage_calculation() {
let temp_dir = TempDir::new().unwrap();
let project_path = temp_dir.path();
create_minimal_rust_project(
project_path,
r#"
fn unused_function() -> i32 {
42
}
fn used_function() -> i32 {
100
}
pub fn main() {
let _ = used_function();
}
"#,
);
let analyzer = CargoBasedDeadCodeAnalyzer::new();
let report = analyzer.analyze(project_path).unwrap();
assert!(
report.percentage < 25.0,
"Dead code percentage should be reasonable for test code with 1 unused function. Got: {}%",
report.percentage
);
}
#[test]
#[serial] fn test_public_api_not_marked_as_dead() {
let temp_dir = TempDir::new().unwrap();
let project_path = temp_dir.path();
create_rust_library_project(
project_path,
r#"
/// Public API function - should never be marked as dead
pub fn public_api() -> String {
"API".to_string()
}
fn internal_helper() -> i32 {
42
}
"#,
);
let analyzer = CargoBasedDeadCodeAnalyzer::new();
let report = analyzer.analyze(project_path).unwrap();
assert_eq!(report.dead_functions.len(), 1);
assert!(report.dead_functions[0].contains("internal_helper"));
assert!(!report
.dead_functions
.iter()
.any(|f| f.contains("public_api")));
}
#[test]
fn test_parse_cargo_json_output() {
let cargo_json = r#"{
"reason":"compiler-message",
"message":{
"code":{"code":"dead_code"},
"level":"warning",
"message":"function `unused_func` is never used",
"spans":[{
"file_name":"src/lib.rs",
"line_start":10,
"line_end":10
}]
}
}"#;
let dead_items = parse_cargo_dead_code_messages(cargo_json);
assert_eq!(dead_items.len(), 1);
assert_eq!(dead_items[0].name, "unused_func");
assert_eq!(dead_items[0].file, "src/lib.rs");
assert_eq!(dead_items[0].line, 10);
}
#[test]
fn test_exclude_test_code_from_analysis() {
let temp_dir = TempDir::new().unwrap();
let project_path = temp_dir.path();
create_minimal_rust_project(
project_path,
r#"
fn production_code() -> i32 {
42
}
#[cfg_attr(coverage_nightly, coverage(off))]
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_function() {
assert_eq!(production_code(), 42);
}
fn test_helper() -> i32 {
100
}
}
pub fn main() {
let _ = production_code();
}
"#,
);
let analyzer = CargoBasedDeadCodeAnalyzer::new();
let report = analyzer.analyze_excluding_tests(project_path).unwrap();
assert_eq!(
report.dead_functions.len(),
0,
"Test helpers should not be counted as dead code"
);
}
fn create_minimal_rust_project(path: &Path, code: &str) {
let src_dir = path.join("src");
fs::create_dir_all(&src_dir).unwrap();
fs::write(src_dir.join("main.rs"), code).unwrap();
fs::write(
path.join("Cargo.toml"),
r#"
[package]
name = "test_project"
version = "0.1.0"
edition = "2021"
"#,
)
.unwrap();
}
fn create_rust_library_project(path: &Path, code: &str) {
let src_dir = path.join("src");
fs::create_dir_all(&src_dir).unwrap();
fs::write(src_dir.join("lib.rs"), code).unwrap();
fs::write(
path.join("Cargo.toml"),
r#"
[package]
name = "test_library"
version = "0.1.0"
edition = "2021"
[lib]
name = "test_library"
"#,
)
.unwrap();
}
fn get_cargo_dead_code_warnings(project_path: &Path) -> usize {
let output = Command::new("cargo")
.arg("check")
.arg("--message-format=json")
.current_dir(project_path)
.output()
.expect("Failed to run cargo check");
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let count = stdout
.lines()
.chain(stderr.lines())
.filter(|line| line.contains(r#""code":"dead_code""#))
.count();
count
}
fn parse_cargo_dead_code_messages(json_output: &str) -> Vec<DeadCodeItem> {
let mut items = Vec::new();
if json_output.contains(r#""code":"dead_code""#) {
if let Some(start) = json_output.find("function `") {
let substr = &json_output[start + 10..];
if let Some(end) = substr.find('`') {
let name = &substr[..end];
items.push(DeadCodeItem {
name: name.to_string(),
file: "src/lib.rs".to_string(),
line: 10,
kind: DeadCodeKind::Function,
});
}
}
}
items
}
#[derive(Debug, Clone)]
struct DeadCodeItem {
name: String,
file: String,
line: usize,
kind: DeadCodeKind,
}
#[derive(Debug, Clone, PartialEq)]
enum DeadCodeKind {
Function,
#[allow(dead_code)] Struct,
#[allow(dead_code)] Enum,
#[allow(dead_code)] Variable,
}
struct CargoBasedDeadCodeAnalyzer;
impl CargoBasedDeadCodeAnalyzer {
fn new() -> Self {
Self
}
fn analyze(&self, project_path: &Path) -> Result<DeadCodeAnalysisReport, String> {
let output = Command::new("cargo")
.arg("check")
.arg("--message-format=json")
.current_dir(project_path)
.output()
.map_err(|e| format!("Failed to run cargo: {}", e))?;
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let combined_output = format!("{}\n{}", stdout, stderr);
let dead_items = self.parse_cargo_output(&combined_output);
let total_lines = self.count_source_lines(project_path)?;
let dead_lines = dead_items.len() * 3; let percentage = if total_lines > 0 {
(dead_lines as f64 / total_lines as f64) * 100.0
} else {
0.0
};
Ok(DeadCodeAnalysisReport {
dead_functions: dead_items
.iter()
.filter(|i| i.kind == DeadCodeKind::Function)
.map(|i| i.name.clone())
.collect(),
percentage,
total_dead_items: dead_items.len(),
})
}
fn analyze_excluding_tests(
&self,
project_path: &Path,
) -> Result<DeadCodeAnalysisReport, String> {
let output = Command::new("cargo")
.arg("check")
.arg("--message-format=json")
.arg("--lib")
.current_dir(project_path)
.output()
.map_err(|e| format!("Failed to run cargo: {}", e))?;
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let combined_output = format!("{}\n{}", stdout, stderr);
let dead_items = self.parse_cargo_output(&combined_output);
Ok(DeadCodeAnalysisReport {
dead_functions: dead_items
.iter()
.filter(|i| i.kind == DeadCodeKind::Function)
.map(|i| i.name.clone())
.collect(),
percentage: 0.0,
total_dead_items: dead_items.len(),
})
}
fn parse_cargo_output(&self, output: &str) -> Vec<DeadCodeItem> {
let mut items = Vec::new();
for line in output.lines() {
if line.contains(r#""code":"dead_code""#) {
if let Some(item) = self.parse_json_message(line) {
items.push(item);
}
}
}
items
}
fn parse_json_message(&self, json: &str) -> Option<DeadCodeItem> {
if json.contains(r#""code":"dead_code""#) && json.contains("function") {
if let Some(start) = json.find("function `") {
let substr = &json[start + 10..];
if let Some(end) = substr.find('`') {
let function_name = &substr[..end];
return Some(DeadCodeItem {
name: function_name.to_string(),
file: "src/lib.rs".to_string(),
line: 1,
kind: DeadCodeKind::Function,
});
}
}
}
None
}
fn count_source_lines(&self, project_path: &Path) -> Result<usize, String> {
let src_path = project_path.join("src");
let mut total_lines = 0;
if src_path.exists() {
for entry in fs::read_dir(src_path).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
if entry.path().extension().and_then(|s| s.to_str()) == Some("rs") {
let content =
fs::read_to_string(entry.path()).map_err(|e| e.to_string())?;
total_lines += content.lines().count();
}
}
}
Ok(total_lines)
}
}
#[derive(Debug)]
struct DeadCodeAnalysisReport {
dead_functions: Vec<String>,
percentage: f64,
#[allow(dead_code)] total_dead_items: usize,
}
}
#[cfg(test)]
mod analyze_project_context_tests {
use crate::services::context::{AstItem, FileContext, ProjectContext, ProjectSummary};
use crate::services::dead_code_analyzer::DeadCodeAnalyzer;
use std::fs;
use tempfile::TempDir;
fn make_project_context(files: Vec<FileContext>) -> ProjectContext {
let total_functions = files
.iter()
.flat_map(|f| &f.items)
.filter(|i| matches!(i, AstItem::Function { .. }))
.count();
ProjectContext {
project_type: "rust".to_string(),
files,
summary: ProjectSummary {
total_files: 0,
total_functions,
total_structs: 0,
total_enums: 0,
total_traits: 0,
total_impls: 0,
dependencies: vec![],
},
graph: None,
}
}
#[test]
fn test_analyze_project_context_single_unused_function() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("lib.rs");
fs::write(
&file_path,
"fn main() {\n println!(\"hello\");\n}\n\nfn unused_helper() -> i32 {\n 42\n}\n",
)
.unwrap();
let ctx = make_project_context(vec![FileContext {
path: file_path.to_string_lossy().to_string(),
language: "rust".to_string(),
items: vec![
AstItem::Function {
name: "main".to_string(),
visibility: "pub".to_string(),
is_async: false,
line: 1,
},
AstItem::Function {
name: "unused_helper".to_string(),
visibility: "".to_string(),
is_async: false,
line: 5,
},
],
complexity_metrics: None,
}]);
let mut analyzer = DeadCodeAnalyzer::new(100);
let report = analyzer.analyze_project_context(&ctx).unwrap();
let dead_names: Vec<&str> = report
.dead_functions
.iter()
.map(|d| d.name.as_str())
.collect();
assert!(
dead_names.contains(&"unused_helper"),
"unused_helper should be dead, got: {:?}",
dead_names
);
assert!(
!dead_names.contains(&"main"),
"main should not be marked dead"
);
}
#[test]
fn test_analyze_project_context_all_reachable() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("lib.rs");
fs::write(
&file_path,
"fn main() {\n let x = helper();\n println!(\"{}\", x);\n}\n\nfn helper() -> i32 {\n 42\n}\n",
)
.unwrap();
let ctx = make_project_context(vec![FileContext {
path: file_path.to_string_lossy().to_string(),
language: "rust".to_string(),
items: vec![
AstItem::Function {
name: "main".to_string(),
visibility: "pub".to_string(),
is_async: false,
line: 1,
},
AstItem::Function {
name: "helper".to_string(),
visibility: "".to_string(),
is_async: false,
line: 6,
},
],
complexity_metrics: None,
}]);
let mut analyzer = DeadCodeAnalyzer::new(100);
let report = analyzer.analyze_project_context(&ctx).unwrap();
assert!(
report.dead_functions.is_empty(),
"All functions should be reachable, but got dead: {:?}",
report
.dead_functions
.iter()
.map(|d| &d.name)
.collect::<Vec<_>>()
);
}
#[test]
fn test_analyze_project_context_no_functions() {
let ctx = make_project_context(vec![FileContext {
path: "/nonexistent/empty.rs".to_string(),
language: "rust".to_string(),
items: vec![],
complexity_metrics: None,
}]);
let mut analyzer = DeadCodeAnalyzer::new(100);
let report = analyzer.analyze_project_context(&ctx).unwrap();
assert!(report.dead_functions.is_empty());
assert_eq!(report.summary.percentage_dead, 0.0);
}
#[test]
fn test_analyze_project_context_pub_entry_point() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("lib.rs");
fs::write(
&file_path,
"pub fn public_api() -> i32 {\n internal_helper()\n}\n\nfn internal_helper() -> i32 {\n 42\n}\n\nfn orphan_func() -> bool {\n true\n}\n",
)
.unwrap();
let ctx = make_project_context(vec![FileContext {
path: file_path.to_string_lossy().to_string(),
language: "rust".to_string(),
items: vec![
AstItem::Function {
name: "pub public_api".to_string(),
visibility: "pub".to_string(),
is_async: false,
line: 1,
},
AstItem::Function {
name: "internal_helper".to_string(),
visibility: "".to_string(),
is_async: false,
line: 5,
},
AstItem::Function {
name: "orphan_func".to_string(),
visibility: "".to_string(),
is_async: false,
line: 9,
},
],
complexity_metrics: None,
}]);
let mut analyzer = DeadCodeAnalyzer::new(100);
let report = analyzer.analyze_project_context(&ctx).unwrap();
let dead_names: Vec<&str> = report
.dead_functions
.iter()
.map(|d| d.name.as_str())
.collect();
assert!(
dead_names.contains(&"orphan_func"),
"orphan_func should be dead, got: {:?}",
dead_names
);
}
#[test]
fn test_analyze_project_context_multiple_files() {
let temp_dir = TempDir::new().unwrap();
let file_a = temp_dir.path().join("a.rs");
let file_b = temp_dir.path().join("b.rs");
fs::write(
&file_a,
"fn main() {\n let x = compute();\n println!(\"{}\", x);\n}\n",
)
.unwrap();
fs::write(
&file_b,
"fn compute() -> i32 {\n 42\n}\n\nfn dead_in_b() -> bool {\n false\n}\n",
)
.unwrap();
let ctx = make_project_context(vec![
FileContext {
path: file_a.to_string_lossy().to_string(),
language: "rust".to_string(),
items: vec![AstItem::Function {
name: "main".to_string(),
visibility: "pub".to_string(),
is_async: false,
line: 1,
}],
complexity_metrics: None,
},
FileContext {
path: file_b.to_string_lossy().to_string(),
language: "rust".to_string(),
items: vec![
AstItem::Function {
name: "compute".to_string(),
visibility: "".to_string(),
is_async: false,
line: 1,
},
AstItem::Function {
name: "dead_in_b".to_string(),
visibility: "".to_string(),
is_async: false,
line: 5,
},
],
complexity_metrics: None,
},
]);
let mut analyzer = DeadCodeAnalyzer::new(100);
let report = analyzer.analyze_project_context(&ctx).unwrap();
let dead_names: Vec<&str> = report
.dead_functions
.iter()
.map(|d| d.name.as_str())
.collect();
assert!(
dead_names.contains(&"dead_in_b"),
"dead_in_b should be detected as dead, got: {:?}",
dead_names
);
}
#[test]
fn test_analyze_project_context_transitive_reachability() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("chain.rs");
fs::write(
&file_path,
"fn main() {\n step_one();\n}\n\nfn step_one() {\n step_two();\n}\n\nfn step_two() {\n step_three();\n}\n\nfn step_three() -> i32 {\n 42\n}\n",
)
.unwrap();
let ctx = make_project_context(vec![FileContext {
path: file_path.to_string_lossy().to_string(),
language: "rust".to_string(),
items: vec![
AstItem::Function {
name: "main".to_string(),
visibility: "pub".to_string(),
is_async: false,
line: 1,
},
AstItem::Function {
name: "step_one".to_string(),
visibility: "".to_string(),
is_async: false,
line: 5,
},
AstItem::Function {
name: "step_two".to_string(),
visibility: "".to_string(),
is_async: false,
line: 9,
},
AstItem::Function {
name: "step_three".to_string(),
visibility: "".to_string(),
is_async: false,
line: 13,
},
],
complexity_metrics: None,
}]);
let mut analyzer = DeadCodeAnalyzer::new(100);
let report = analyzer.analyze_project_context(&ctx).unwrap();
assert!(
report.dead_functions.is_empty(),
"All functions transitively reachable from main, got dead: {:?}",
report
.dead_functions
.iter()
.map(|d| &d.name)
.collect::<Vec<_>>()
);
}
#[test]
fn test_analyze_project_context_file_not_readable() {
let ctx = make_project_context(vec![FileContext {
path: "/nonexistent/path/missing.rs".to_string(),
language: "rust".to_string(),
items: vec![
AstItem::Function {
name: "main".to_string(),
visibility: "pub".to_string(),
is_async: false,
line: 1,
},
AstItem::Function {
name: "unreachable_fn".to_string(),
visibility: "".to_string(),
is_async: false,
line: 5,
},
],
complexity_metrics: None,
}]);
let mut analyzer = DeadCodeAnalyzer::new(100);
let report = analyzer.analyze_project_context(&ctx).unwrap();
let dead_names: Vec<&str> = report
.dead_functions
.iter()
.map(|d| d.name.as_str())
.collect();
assert!(
dead_names.contains(&"unreachable_fn"),
"unreachable_fn should be dead when file is not readable"
);
}
#[test]
fn test_analyze_project_context_percentage_calculation() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("pct.rs");
fs::write(
&file_path,
"fn main() {}\nfn dead_a() {}\nfn dead_b() {}\nfn dead_c() {}\n",
)
.unwrap();
let ctx = make_project_context(vec![FileContext {
path: file_path.to_string_lossy().to_string(),
language: "rust".to_string(),
items: vec![
AstItem::Function {
name: "main".to_string(),
visibility: "pub".to_string(),
is_async: false,
line: 1,
},
AstItem::Function {
name: "dead_a".to_string(),
visibility: "".to_string(),
is_async: false,
line: 2,
},
AstItem::Function {
name: "dead_b".to_string(),
visibility: "".to_string(),
is_async: false,
line: 3,
},
AstItem::Function {
name: "dead_c".to_string(),
visibility: "".to_string(),
is_async: false,
line: 4,
},
],
complexity_metrics: None,
}]);
let mut analyzer = DeadCodeAnalyzer::new(100);
let report = analyzer.analyze_project_context(&ctx).unwrap();
assert_eq!(report.dead_functions.len(), 3);
assert!(report.summary.percentage_dead > 50.0);
}
#[test]
fn test_analyze_project_context_empty_files_list() {
let ctx = make_project_context(vec![]);
let mut analyzer = DeadCodeAnalyzer::new(100);
let report = analyzer.analyze_project_context(&ctx).unwrap();
assert!(report.dead_functions.is_empty());
assert!(report.dead_classes.is_empty());
assert!(report.dead_variables.is_empty());
assert_eq!(report.summary.percentage_dead, 0.0);
}
#[test]
fn test_analyze_project_context_non_function_items_ignored() {
let ctx = make_project_context(vec![FileContext {
path: "/nonexistent/structs.rs".to_string(),
language: "rust".to_string(),
items: vec![
AstItem::Struct {
name: "MyStruct".to_string(),
visibility: "pub".to_string(),
fields_count: 3,
derives: vec!["Debug".to_string()],
line: 1,
},
AstItem::Enum {
name: "MyEnum".to_string(),
visibility: "pub".to_string(),
variants_count: 2,
line: 5,
},
AstItem::Trait {
name: "MyTrait".to_string(),
visibility: "pub".to_string(),
line: 10,
},
],
complexity_metrics: None,
}]);
let mut analyzer = DeadCodeAnalyzer::new(100);
let report = analyzer.analyze_project_context(&ctx).unwrap();
assert!(report.dead_functions.is_empty());
}
}