use crate::priority::call_graph::{CallGraph, FunctionId};
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
#[derive(Debug, Clone, Default)]
pub struct CallGraphValidationConfig {
pub orphan_whitelist: HashSet<String>,
pub additional_entry_points: HashSet<String>,
}
impl CallGraphValidationConfig {
pub fn new() -> Self {
Self::default()
}
pub fn add_orphan_whitelist(&mut self, function_name: String) -> &mut Self {
self.orphan_whitelist.insert(function_name);
self
}
pub fn add_entry_point(&mut self, function_name: String) -> &mut Self {
self.additional_entry_points.insert(function_name);
self
}
pub fn is_whitelisted_orphan(&self, function: &FunctionId) -> bool {
self.orphan_whitelist.contains(&function.name)
}
pub fn is_additional_entry_point(&self, function: &FunctionId) -> bool {
self.additional_entry_points.contains(&function.name)
}
}
#[derive(Debug, Clone)]
pub enum StructuralIssue {
DanglingEdge {
caller: FunctionId,
callee: FunctionId,
},
UnreachableFunction {
function: FunctionId,
reason: UnreachableReason,
},
IsolatedFunction { function: FunctionId },
DuplicateNode { function: FunctionId, count: usize },
}
#[derive(Debug, Clone)]
pub enum UnreachableReason {
NoCallers,
}
#[derive(Debug, Clone)]
pub enum ValidationWarning {
TooManyCallers { function: FunctionId, count: usize },
TooManyCallees { function: FunctionId, count: usize },
FileWithNoCalls {
file: PathBuf,
function_count: usize,
},
UnusedPublicFunction { function: FunctionId },
}
#[derive(Debug, Clone)]
pub enum ValidationInfo {
LeafFunction {
function: FunctionId,
caller_count: usize,
},
SelfReferentialFunction { function: FunctionId },
}
#[derive(Debug, Clone)]
pub struct ValidationReport {
pub structural_issues: Vec<StructuralIssue>,
pub warnings: Vec<ValidationWarning>,
pub info: Vec<ValidationInfo>,
pub health_score: u32,
pub statistics: ValidationStatistics,
}
#[derive(Debug, Clone, Default)]
pub struct ValidationStatistics {
pub total_functions: usize,
pub entry_points: usize,
pub leaf_functions: usize,
pub unreachable_functions: usize,
pub isolated_functions: usize,
pub recursive_functions: usize,
}
impl ValidationReport {
fn new() -> Self {
Self {
structural_issues: Vec::new(),
warnings: Vec::new(),
info: Vec::new(),
health_score: 100,
statistics: ValidationStatistics::default(),
}
}
fn calculate_health_score(&mut self) {
let mut score: u32 = 100;
let mut unreachable_count = 0;
let mut isolated_count = 0;
let mut dangling_edge_count = 0;
let mut duplicate_count = 0;
for issue in &self.structural_issues {
match issue {
StructuralIssue::UnreachableFunction { .. } => unreachable_count += 1,
StructuralIssue::IsolatedFunction { .. } => isolated_count += 1,
StructuralIssue::DanglingEdge { .. } => dangling_edge_count += 1,
StructuralIssue::DuplicateNode { .. } => duplicate_count += 1,
}
}
score = score.saturating_sub(dangling_edge_count * 10);
score = score.saturating_sub(duplicate_count * 5);
score = score.saturating_sub(unreachable_count);
score = score.saturating_sub((isolated_count as f32 * 0.5) as u32);
score = score.saturating_sub(self.warnings.len() as u32 * 2);
self.health_score = score;
}
pub fn has_issues(&self) -> bool {
!self.structural_issues.is_empty() || !self.warnings.is_empty()
}
}
pub struct CallGraphValidator;
impl CallGraphValidator {
pub fn validate(call_graph: &CallGraph) -> ValidationReport {
Self::validate_with_config(call_graph, &CallGraphValidationConfig::default())
}
pub fn validate_with_config(
call_graph: &CallGraph,
config: &CallGraphValidationConfig,
) -> ValidationReport {
let mut report = ValidationReport::new();
Self::check_dangling_edges(call_graph, &mut report);
Self::check_orphaned_nodes(call_graph, &mut report, config);
Self::check_duplicate_nodes(call_graph, &mut report);
Self::check_heuristics(call_graph, &mut report);
report.calculate_health_score();
report
}
fn check_dangling_edges(call_graph: &CallGraph, report: &mut ValidationReport) {
let all_function_ids: HashSet<_> = call_graph.get_all_functions().cloned().collect();
for function in call_graph.get_all_functions() {
let callees = call_graph.get_callees(function);
for callee in callees {
if !all_function_ids.contains(&callee) {
report
.structural_issues
.push(StructuralIssue::DanglingEdge {
caller: function.clone(),
callee: callee.clone(),
});
}
}
}
}
fn is_entry_point(function: &FunctionId, config: &CallGraphValidationConfig) -> bool {
if config.is_additional_entry_point(function) {
return true;
}
if function.name == "main" {
return true;
}
if function.name.starts_with("test_")
|| function.name.contains("::test_")
|| function.name.starts_with("#[test]")
{
return true;
}
if function.name.starts_with("bench_")
|| function.name.contains("::bench_")
|| function.name.starts_with("#[bench]")
{
return true;
}
if let Some(path_str) = function.file.to_str() {
if path_str.contains("/examples/")
|| path_str.contains("/benches/")
|| path_str.starts_with("examples/")
|| path_str.starts_with("benches/")
{
return true;
}
}
if let Some(file_name) = function.file.file_name().and_then(|s| s.to_str()) {
if file_name == "lib.rs" || file_name == "main.rs" {
if function.name.len() < 30 && !function.name.contains("::") {
return true;
}
}
}
if function.name.contains("::") {
let trait_methods = [
"default",
"new",
"clone",
"clone_box",
"clone_from",
"from",
"into",
"fmt",
"display",
"debug",
"drop",
"deref",
"deref_mut",
"hash",
"eq",
"builder",
"create",
"with_",
"try_from",
"try_into",
];
let name_lower = function.name.to_lowercase();
if trait_methods
.iter()
.any(|&method| name_lower.contains(method))
{
return true;
}
}
if function.name == "new"
|| function.name == "builder"
|| function.name == "create"
|| function.name.starts_with("with_")
{
return true;
}
false
}
fn is_self_referential(function: &FunctionId, call_graph: &CallGraph) -> bool {
let callees = call_graph.get_callees(function);
callees.iter().any(|callee| callee == function)
}
fn check_orphaned_nodes(
call_graph: &CallGraph,
report: &mut ValidationReport,
config: &CallGraphValidationConfig,
) {
for function in call_graph.get_all_functions() {
let has_callers = !call_graph.get_callers(function).is_empty();
let has_callees = !call_graph.get_callees(function).is_empty();
let is_entry_point = Self::is_entry_point(function, config);
let is_self_referential = Self::is_self_referential(function, call_graph);
let is_whitelisted = config.is_whitelisted_orphan(function);
report.statistics.total_functions += 1;
if is_entry_point {
report.statistics.entry_points += 1;
}
if is_self_referential {
report.statistics.recursive_functions += 1;
report.info.push(ValidationInfo::SelfReferentialFunction {
function: function.clone(),
});
}
if has_callers && !has_callees {
report.statistics.leaf_functions += 1;
report.info.push(ValidationInfo::LeafFunction {
function: function.clone(),
caller_count: call_graph.get_callers(function).len(),
});
continue; }
if is_self_referential {
continue; }
if !has_callers && !has_callees && !is_entry_point {
report.statistics.isolated_functions += 1;
if !is_whitelisted {
report
.structural_issues
.push(StructuralIssue::IsolatedFunction {
function: function.clone(),
});
}
continue;
}
if !has_callers && has_callees && !is_entry_point {
report.statistics.unreachable_functions += 1;
if !is_whitelisted {
report
.structural_issues
.push(StructuralIssue::UnreachableFunction {
function: function.clone(),
reason: UnreachableReason::NoCallers,
});
}
}
}
}
fn check_duplicate_nodes(call_graph: &CallGraph, report: &mut ValidationReport) {
let mut function_counts: HashMap<String, Vec<FunctionId>> = HashMap::new();
for function in call_graph.get_all_functions() {
let key = format!("{}:{}", function.file.display(), function.name);
function_counts
.entry(key)
.or_default()
.push(function.clone());
}
for (_, functions) in function_counts {
if functions.len() > 1 {
report
.structural_issues
.push(StructuralIssue::DuplicateNode {
function: functions[0].clone(),
count: functions.len(),
});
}
}
}
fn check_heuristics(call_graph: &CallGraph, report: &mut ValidationReport) {
Self::check_high_fan_in(call_graph, report);
Self::check_high_fan_out(call_graph, report);
Self::check_files_with_no_calls(call_graph, report);
Self::check_unused_public_functions(call_graph, report);
}
fn check_high_fan_in(call_graph: &CallGraph, report: &mut ValidationReport) {
const HIGH_CALLER_THRESHOLD: usize = 50;
for function in call_graph.get_all_functions() {
let callers = call_graph.get_callers(function);
if callers.len() > HIGH_CALLER_THRESHOLD {
report.warnings.push(ValidationWarning::TooManyCallers {
function: function.clone(),
count: callers.len(),
});
}
}
}
fn check_high_fan_out(call_graph: &CallGraph, report: &mut ValidationReport) {
const HIGH_CALLEE_THRESHOLD: usize = 50;
for function in call_graph.get_all_functions() {
let callees = call_graph.get_callees(function);
if callees.len() > HIGH_CALLEE_THRESHOLD {
report.warnings.push(ValidationWarning::TooManyCallees {
function: function.clone(),
count: callees.len(),
});
}
}
}
fn check_files_with_no_calls(call_graph: &CallGraph, report: &mut ValidationReport) {
let mut file_functions: HashMap<PathBuf, Vec<FunctionId>> = HashMap::new();
for function in call_graph.get_all_functions() {
file_functions
.entry(function.file.clone())
.or_default()
.push(function.clone());
}
for (file, functions) in file_functions {
if functions.is_empty() {
continue;
}
let all_have_no_callers = functions
.iter()
.all(|func| call_graph.get_callers(func).is_empty());
let has_entry_point = functions.iter().any(|f| {
f.name == "main" || f.name.starts_with("test_") || f.name.contains("::test_")
});
if all_have_no_callers && !has_entry_point && functions.len() >= 3 {
report.warnings.push(ValidationWarning::FileWithNoCalls {
file: file.clone(),
function_count: functions.len(),
});
}
}
}
fn check_unused_public_functions(call_graph: &CallGraph, report: &mut ValidationReport) {
for function in call_graph.get_all_functions() {
let is_standalone = !function.name.contains("::");
let starts_lowercase = function
.name
.chars()
.next()
.map(|c| c.is_lowercase())
.unwrap_or(false);
if is_standalone && starts_lowercase {
let has_no_callers = call_graph.get_callers(function).is_empty();
let is_entry_point = function.name == "main"
|| function.name.starts_with("test_")
|| function.name.contains("::test_");
if has_no_callers && !is_entry_point {
report
.warnings
.push(ValidationWarning::UnusedPublicFunction {
function: function.clone(),
});
}
}
}
}
pub fn validate_expectations(
call_graph: &CallGraph,
expectations: &[Expectation],
) -> ValidationReport {
Self::validate_expectations_with_config(
call_graph,
expectations,
&CallGraphValidationConfig::default(),
)
}
pub fn validate_expectations_with_config(
call_graph: &CallGraph,
expectations: &[Expectation],
config: &CallGraphValidationConfig,
) -> ValidationReport {
let report = Self::validate_with_config(call_graph, config);
for expectation in expectations {
if !expectation.check(call_graph) {
}
}
report
}
}
#[derive(Debug, Clone)]
pub struct Expectation {
pub description: String,
pub check: fn(&CallGraph) -> bool,
}
impl Expectation {
pub fn new(description: String, check: fn(&CallGraph) -> bool) -> Self {
Self { description, check }
}
pub fn check(&self, call_graph: &CallGraph) -> bool {
(self.check)(call_graph)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validation_report_creation() {
let report = ValidationReport::new();
assert_eq!(report.health_score, 100);
assert!(!report.has_issues());
}
#[test]
fn test_health_score_calculation() {
let mut report = ValidationReport::new();
report
.structural_issues
.push(StructuralIssue::IsolatedFunction {
function: FunctionId::new(PathBuf::from("test.rs"), "func".to_string(), 10),
});
report.warnings.push(ValidationWarning::TooManyCallers {
function: FunctionId::new(PathBuf::from("test.rs"), "popular".to_string(), 20),
count: 100,
});
report.calculate_health_score();
assert_eq!(report.health_score, 98);
assert!(report.has_issues());
}
#[test]
fn test_validate_empty_graph() {
let graph = CallGraph::new();
let report = CallGraphValidator::validate(&graph);
assert!(!report.has_issues());
assert_eq!(report.health_score, 100);
}
#[test]
fn test_expectation() {
let expectation = Expectation::new("Has at least one function".to_string(), |graph| {
graph.node_count() > 0
});
let empty_graph = CallGraph::new();
assert!(!expectation.check(&empty_graph));
}
#[test]
fn test_leaf_function_not_orphaned() {
let mut call_graph = CallGraph::new();
let leaf = FunctionId::new(PathBuf::from("test.rs"), "utility_fn".to_string(), 10);
let caller = FunctionId::new(PathBuf::from("test.rs"), "main_fn".to_string(), 5);
call_graph.add_function(leaf.clone(), false, false, 1, 10);
call_graph.add_function(caller.clone(), false, false, 1, 5);
call_graph.add_call_parts(
caller,
leaf.clone(),
crate::priority::call_graph::CallType::Direct,
);
let report = CallGraphValidator::validate(&call_graph);
assert!(
!report
.structural_issues
.iter()
.any(|issue| matches!(issue, StructuralIssue::IsolatedFunction { .. })),
"Leaf function should not be flagged as isolated"
);
assert!(
report
.info
.iter()
.any(|info| matches!(info, ValidationInfo::LeafFunction { .. })),
"Leaf function should be in info"
);
}
#[test]
fn test_self_referential_not_isolated() {
let mut call_graph = CallGraph::new();
let recursive = FunctionId::new(PathBuf::from("test.rs"), "factorial".to_string(), 10);
call_graph.add_function(recursive.clone(), false, false, 1, 10);
call_graph.add_call_parts(
recursive.clone(),
recursive.clone(),
crate::priority::call_graph::CallType::Direct,
);
let report = CallGraphValidator::validate(&call_graph);
assert!(
!report
.structural_issues
.iter()
.any(|issue| matches!(issue, StructuralIssue::IsolatedFunction { .. })),
"Recursive function should not be flagged as isolated"
);
assert!(
report
.info
.iter()
.any(|info| matches!(info, ValidationInfo::SelfReferentialFunction { .. })),
"Recursive function should be in info"
);
}
#[test]
fn test_entry_point_detection() {
let config = CallGraphValidationConfig::default();
let test_cases = vec![
("src/main.rs", "main"),
("src/lib.rs", "test_my_function"),
("examples/demo.rs", "demo_main"),
("benches/my_bench.rs", "bench_performance"),
("src/traits.rs", "Default::default"),
("src/types.rs", "MyType::new"),
];
for (file, name) in test_cases {
let func = FunctionId::new(PathBuf::from(file), name.to_string(), 1);
assert!(
CallGraphValidator::is_entry_point(&func, &config),
"Expected {} in {} to be entry point",
name,
file
);
}
}
#[test]
fn test_isolated_function_detected() {
let mut call_graph = CallGraph::new();
let isolated = FunctionId::new(PathBuf::from("test.rs"), "unused_fn".to_string(), 10);
call_graph.add_function(isolated.clone(), false, false, 1, 10);
let report = CallGraphValidator::validate(&call_graph);
assert!(
report.structural_issues.iter().any(
|issue| matches!(issue, StructuralIssue::IsolatedFunction { function } if function == &isolated)
),
"Isolated function should be detected"
);
}
#[test]
fn test_health_score_improved() {
let mut call_graph = CallGraph::new();
let main = FunctionId::new(PathBuf::from("test.rs"), "main".to_string(), 1);
call_graph.add_function(main.clone(), true, false, 1, 5);
for i in 0..1000 {
let leaf = FunctionId::new(PathBuf::from("test.rs"), format!("leaf_{}", i), i * 10);
call_graph.add_function(leaf.clone(), false, false, 1, 10);
call_graph.add_call_parts(
main.clone(),
leaf,
crate::priority::call_graph::CallType::Direct,
);
}
let report = CallGraphValidator::validate(&call_graph);
assert!(
report.health_score >= 80,
"Health score should be 80+ for leaf functions, got {}",
report.health_score
);
}
#[test]
fn test_unreachable_function_detected() {
let mut call_graph = CallGraph::new();
let unreachable = FunctionId::new(PathBuf::from("test.rs"), "dead_code".to_string(), 10);
let callee = FunctionId::new(PathBuf::from("test.rs"), "helper".to_string(), 20);
call_graph.add_function(unreachable.clone(), false, false, 1, 10);
call_graph.add_function(callee.clone(), false, false, 1, 5);
call_graph.add_call_parts(
unreachable.clone(),
callee,
crate::priority::call_graph::CallType::Direct,
);
let report = CallGraphValidator::validate(&call_graph);
assert!(
report.structural_issues.iter().any(
|issue| matches!(issue, StructuralIssue::UnreachableFunction { function, .. } if function == &unreachable)
),
"Unreachable function should be detected"
);
}
#[test]
fn test_statistics_collected() {
let mut call_graph = CallGraph::new();
let main = FunctionId::new(PathBuf::from("main.rs"), "main".to_string(), 1);
call_graph.add_function(main.clone(), true, false, 1, 5);
let leaf = FunctionId::new(PathBuf::from("test.rs"), "utility".to_string(), 10);
call_graph.add_function(leaf.clone(), false, false, 1, 10);
call_graph.add_call_parts(
main.clone(),
leaf,
crate::priority::call_graph::CallType::Direct,
);
let recursive = FunctionId::new(PathBuf::from("test.rs"), "factorial".to_string(), 20);
call_graph.add_function(recursive.clone(), false, false, 1, 15);
call_graph.add_call_parts(
recursive.clone(),
recursive.clone(),
crate::priority::call_graph::CallType::Direct,
);
let report = CallGraphValidator::validate(&call_graph);
assert_eq!(report.statistics.total_functions, 3);
assert_eq!(report.statistics.entry_points, 1);
assert_eq!(report.statistics.leaf_functions, 1);
assert_eq!(report.statistics.recursive_functions, 1);
assert_eq!(report.statistics.isolated_functions, 0);
assert_eq!(report.statistics.unreachable_functions, 0);
}
#[test]
fn test_orphan_whitelist() {
let mut call_graph = CallGraph::new();
let isolated = FunctionId::new(PathBuf::from("test.rs"), "utility_fn".to_string(), 10);
call_graph.add_function(isolated.clone(), false, false, 1, 10);
let report = CallGraphValidator::validate(&call_graph);
assert!(
report.structural_issues.iter().any(
|issue| matches!(issue, StructuralIssue::IsolatedFunction { function } if function == &isolated)
),
"Isolated function should be detected without whitelist"
);
let mut config = CallGraphValidationConfig::new();
config.add_orphan_whitelist("utility_fn".to_string());
let report_with_config = CallGraphValidator::validate_with_config(&call_graph, &config);
assert!(
!report_with_config.structural_issues.iter().any(
|issue| matches!(issue, StructuralIssue::IsolatedFunction { function } if function == &isolated)
),
"Whitelisted function should not be flagged as isolated"
);
}
#[test]
fn test_additional_entry_points() {
let mut call_graph = CallGraph::new();
let custom_entry = FunctionId::new(PathBuf::from("test.rs"), "custom_main".to_string(), 10);
let helper = FunctionId::new(PathBuf::from("test.rs"), "helper".to_string(), 20);
call_graph.add_function(custom_entry.clone(), false, false, 1, 10);
call_graph.add_function(helper.clone(), false, false, 1, 5);
call_graph.add_call_parts(
custom_entry.clone(),
helper,
crate::priority::call_graph::CallType::Direct,
);
let report = CallGraphValidator::validate(&call_graph);
assert!(
report.structural_issues.iter().any(
|issue| matches!(issue, StructuralIssue::UnreachableFunction { function, .. } if function == &custom_entry)
),
"Custom entry point should be unreachable without config"
);
let mut config = CallGraphValidationConfig::new();
config.add_entry_point("custom_main".to_string());
let report_with_config = CallGraphValidator::validate_with_config(&call_graph, &config);
assert!(
!report_with_config.structural_issues.iter().any(
|issue| matches!(issue, StructuralIssue::UnreachableFunction { function, .. } if function == &custom_entry)
),
"Configured entry point should not be flagged as unreachable"
);
assert_eq!(report_with_config.statistics.entry_points, 1);
}
#[test]
fn test_config_builder_pattern() {
let mut config = CallGraphValidationConfig::new();
config
.add_orphan_whitelist("temp_fn".to_string())
.add_orphan_whitelist("debug_fn".to_string())
.add_entry_point("custom_entry".to_string());
assert_eq!(config.orphan_whitelist.len(), 2);
assert_eq!(config.additional_entry_points.len(), 1);
assert!(config.orphan_whitelist.contains("temp_fn"));
assert!(config.additional_entry_points.contains("custom_entry"));
}
#[test]
#[ignore] fn test_real_project_health_score() {
use crate::builders::call_graph;
use crate::core::Language;
use crate::io::walker;
use std::env;
let project_root = env::current_dir().expect("Failed to get current directory");
let config = crate::config::get_config();
let files =
walker::find_project_files_with_config(&project_root, vec![Language::Rust], config)
.expect("Failed to find project files");
let file_metrics: Vec<_> = files
.iter()
.filter_map(|path| crate::analysis_utils::analyze_single_file(path))
.collect();
let all_functions: Vec<_> = file_metrics
.iter()
.flat_map(|fm| fm.complexity.functions.clone())
.collect();
let mut call_graph = call_graph::build_initial_call_graph(&all_functions);
call_graph::process_rust_files_for_call_graph(
&project_root,
&mut call_graph,
false,
false,
|_| {}, )
.expect("Failed to process Rust files");
let validation_report = CallGraphValidator::validate(&call_graph);
assert!(
validation_report.health_score >= 70,
"Health score {} is below threshold 70. Structural issues: {}, Warnings: {}",
validation_report.health_score,
validation_report.structural_issues.len(),
validation_report.warnings.len()
);
assert!(
validation_report.statistics.isolated_functions < 500,
"Isolated functions {} exceeds threshold 500",
validation_report.statistics.isolated_functions
);
eprintln!("\n=== Debtmap Self-Analysis Health Report ===");
eprintln!("Health Score: {}/100", validation_report.health_score);
eprintln!(
"Total Functions: {}",
validation_report.statistics.total_functions
);
eprintln!(
"Entry Points: {}",
validation_report.statistics.entry_points
);
eprintln!(
"Isolated Functions: {}",
validation_report.statistics.isolated_functions
);
eprintln!(
"Structural Issues: {}",
validation_report.structural_issues.len()
);
eprintln!("Warnings: {}", validation_report.warnings.len());
}
}