use crate::priority::call_graph::FunctionId;
use std::collections::{HashMap, HashSet};
use std::io::Write;
use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ResolutionStrategy {
Exact,
Fuzzy,
NameOnly,
}
#[derive(Debug, Clone)]
pub enum FailureReason {
NoCandidates,
Ambiguous(Vec<FunctionId>),
FilteredOut(String),
NotApplicable,
}
#[derive(Debug, Clone)]
pub struct StrategyAttempt {
pub strategy: ResolutionStrategy,
pub candidates: Vec<FunctionId>,
pub failure_reason: Option<FailureReason>,
pub confidence: Option<f32>,
}
#[derive(Debug, Clone)]
pub struct ResolutionAttempt {
pub caller: FunctionId,
pub callee_name: String,
pub strategy_attempts: Vec<StrategyAttempt>,
pub result: Option<FunctionId>,
pub duration: Duration,
}
#[derive(Debug, Clone, Default)]
pub struct StrategyStats {
pub attempts: usize,
pub successes: usize,
pub failures: usize,
pub avg_confidence: f32,
}
#[derive(Debug, Clone, Default)]
pub struct Percentiles {
pub p50: Duration,
pub p95: Duration,
pub p99: Duration,
}
impl Percentiles {
fn from_sorted(durations: &[Duration]) -> Self {
if durations.is_empty() {
return Self::default();
}
let p50_idx = durations.len() / 2;
let p95_idx = (durations.len() * 95) / 100;
let p99_idx = (durations.len() * 99) / 100;
Self {
p50: durations.get(p50_idx).copied().unwrap_or_default(),
p95: durations.get(p95_idx).copied().unwrap_or_default(),
p99: durations.get(p99_idx).copied().unwrap_or_default(),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct ResolutionStatistics {
pub total_attempts: usize,
pub resolved: usize,
pub failed: usize,
pub by_strategy: HashMap<ResolutionStrategy, StrategyStats>,
pub time_percentiles: Percentiles,
}
impl ResolutionStatistics {
pub fn success_rate(&self) -> f64 {
if self.total_attempts == 0 {
0.0
} else {
(self.resolved as f64 / self.total_attempts as f64) * 100.0
}
}
}
#[derive(Debug, Clone, Copy)]
pub enum DebugFormat {
Text,
Json,
}
#[derive(Debug, Clone)]
pub struct DebugConfig {
pub show_successes: bool,
pub show_timing: bool,
pub max_candidates_shown: usize,
pub format: DebugFormat,
pub filter_functions: Option<HashSet<String>>,
}
impl Default for DebugConfig {
fn default() -> Self {
Self {
show_successes: false,
show_timing: true,
max_candidates_shown: 5,
format: DebugFormat::Text,
filter_functions: None,
}
}
}
fn format_header() -> String {
"Call Graph Debug Report\n════════════════════════════════════════\n\n".to_string()
}
fn format_statistics(stats: &ResolutionStatistics) -> String {
format!(
"RESOLUTION STATISTICS\n\
\x20 Total Attempts: {}\n\
\x20 Resolved: {} ({:.1}%)\n\
\x20 Failed: {} ({:.1}%)\n\n",
stats.total_attempts,
stats.resolved,
stats.success_rate(),
stats.failed,
100.0 - stats.success_rate()
)
}
fn format_strategy_breakdown(by_strategy: &HashMap<ResolutionStrategy, StrategyStats>) -> String {
if by_strategy.is_empty() {
return String::new();
}
let strategy_lines: String = by_strategy
.iter()
.map(|(strategy, stats)| {
let success_rate = if stats.attempts > 0 {
(stats.successes as f64 / stats.attempts as f64) * 100.0
} else {
0.0
};
format!(
" {:?}: {} attempts ({:.1}% success)\n",
strategy, stats.attempts, success_rate
)
})
.collect();
format!(" By Strategy:\n{}\n", strategy_lines)
}
fn format_timing(percentiles: &Percentiles, show_timing: bool) -> String {
if !show_timing {
return String::new();
}
format!(
" Resolution Time:\n\
\x20 p50: {:.2}ms\n\
\x20 p95: {:.2}ms\n\
\x20 p99: {:.2}ms\n\n",
percentiles.p50.as_secs_f64() * 1000.0,
percentiles.p95.as_secs_f64() * 1000.0,
percentiles.p99.as_secs_f64() * 1000.0
)
}
fn format_failure_reason(reason: &FailureReason, max_candidates: usize) -> String {
match reason {
FailureReason::NoCandidates => "No candidates\n".to_string(),
FailureReason::Ambiguous(candidates) => {
let candidate_lines: String = candidates
.iter()
.take(max_candidates)
.map(|c| {
format!(
" \u{2022} {} ({}:{})\n",
c.name,
c.file.display(),
c.line
)
})
.collect();
format!(
"Found {} candidates (ambiguous)\n{}",
candidates.len(),
candidate_lines
)
}
FailureReason::FilteredOut(reason) => format!("Filtered out: {}\n", reason),
FailureReason::NotApplicable => "Not applicable\n".to_string(),
}
}
fn format_strategy_attempt(
strategy_attempt: &StrategyAttempt,
idx: usize,
max_candidates: usize,
) -> String {
let prefix = format!(
" {}. {:?} \u{2192} ",
idx + 1,
strategy_attempt.strategy
);
let result = match &strategy_attempt.failure_reason {
Some(reason) => format_failure_reason(reason, max_candidates),
None => format!("Found {} candidates\n", strategy_attempt.candidates.len()),
};
format!("{}{}", prefix, result)
}
fn format_single_failure(attempt: &ResolutionAttempt, idx: usize, max_candidates: usize) -> String {
let header = format!(
" {}. {}\n\
\x20 Called from: {}\n\
\x20 Location: {}:{}\n\n\
\x20 Strategy Attempts:\n",
idx + 1,
attempt.callee_name,
attempt.caller.name,
attempt.caller.file.display(),
attempt.caller.line
);
let attempts: String = attempt
.strategy_attempts
.iter()
.enumerate()
.map(|(i, sa)| format_strategy_attempt(sa, i, max_candidates))
.collect();
format!("{}{}\n", header, attempts)
}
fn format_failed_resolutions(failures: &[&ResolutionAttempt], max_candidates: usize) -> String {
if failures.is_empty() {
return String::new();
}
const MAX_DISPLAYED: usize = 20;
let header = format!("[ERROR] FAILED RESOLUTIONS ({} total)\n\n", failures.len());
let items: String = failures
.iter()
.enumerate()
.take(MAX_DISPLAYED)
.map(|(idx, attempt)| format_single_failure(attempt, idx, max_candidates))
.collect();
let overflow = if failures.len() > MAX_DISPLAYED {
format!(
" ... and {} more failed resolutions\n\n",
failures.len() - MAX_DISPLAYED
)
} else {
String::new()
};
format!("{}{}{}", header, items, overflow)
}
fn format_recommendations(stats: &ResolutionStatistics) -> String {
let success_rate = stats.success_rate();
let rate_assessment = if success_rate >= 95.0 {
format!(
" \u{2022} {:.1}% resolution rate is excellent (target: >95%)\n",
success_rate
)
} else if success_rate >= 85.0 {
format!(
" \u{2022} {:.1}% resolution rate is good (target: >95%)\n",
success_rate
)
} else {
format!(
" \u{2022} {:.1}% resolution rate needs improvement (target: >95%)\n",
success_rate
)
};
let failed_investigation = if stats.failed > 0 {
format!(
" \u{2022} Investigate {} failed resolutions for patterns\n",
stats.failed
)
} else {
String::new()
};
format!(
"\u{1F4C8} RECOMMENDATIONS\n{}{}",
rate_assessment, failed_investigation
)
}
pub struct CallGraphDebugger {
attempts: Vec<ResolutionAttempt>,
trace_functions: HashSet<String>,
stats: ResolutionStatistics,
config: DebugConfig,
}
impl CallGraphDebugger {
pub fn new(config: DebugConfig) -> Self {
Self {
attempts: Vec::new(),
trace_functions: HashSet::new(),
stats: ResolutionStatistics::default(),
config,
}
}
pub fn add_trace_function(&mut self, name: String) {
self.trace_functions.insert(name);
}
pub fn should_trace(&self, function_name: &str) -> bool {
if self.trace_functions.is_empty() {
return true; }
self.trace_functions.iter().any(|trace_name| {
function_name.contains(trace_name) || trace_name.contains(function_name)
})
}
pub fn record_attempt(&mut self, attempt: ResolutionAttempt) {
self.stats.total_attempts += 1;
if attempt.result.is_some() {
self.stats.resolved += 1;
} else {
self.stats.failed += 1;
}
for strategy_attempt in &attempt.strategy_attempts {
let stats = self
.stats
.by_strategy
.entry(strategy_attempt.strategy)
.or_default();
stats.attempts += 1;
if strategy_attempt.failure_reason.is_none() && strategy_attempt.confidence.is_some() {
stats.successes += 1;
if let Some(confidence) = strategy_attempt.confidence {
stats.avg_confidence = (stats.avg_confidence * (stats.successes - 1) as f32
+ confidence)
/ stats.successes as f32;
}
} else {
stats.failures += 1;
}
}
if self.should_trace(&attempt.caller.name) || self.should_trace(&attempt.callee_name) {
self.attempts.push(attempt);
}
}
pub fn statistics(&self) -> &ResolutionStatistics {
&self.stats
}
pub fn failed_resolutions(&self) -> Vec<&ResolutionAttempt> {
self.attempts
.iter()
.filter(|attempt| attempt.result.is_none())
.collect()
}
pub fn finalize_statistics(&mut self) {
let mut durations: Vec<Duration> = self.attempts.iter().map(|a| a.duration).collect();
durations.sort();
self.stats.time_percentiles = Percentiles::from_sorted(&durations);
}
fn generate_text_report(&self) -> String {
let failures = self.failed_resolutions();
[
format_header(),
format_statistics(&self.stats),
format_strategy_breakdown(&self.stats.by_strategy),
format_timing(&self.stats.time_percentiles, self.config.show_timing),
format_failed_resolutions(&failures, self.config.max_candidates_shown),
format_recommendations(&self.stats),
]
.concat()
}
fn generate_json_report(&self) -> String {
use serde_json::json;
let failed_resolutions: Vec<_> = self
.failed_resolutions()
.iter()
.map(|attempt| {
json!({
"caller": {
"function": attempt.caller.name,
"file": attempt.caller.file.display().to_string(),
"line": attempt.caller.line
},
"callee_name": attempt.callee_name,
"attempts": attempt.strategy_attempts.iter().map(|sa| {
let mut obj = json!({
"strategy": format!("{:?}", sa.strategy),
"candidates": sa.candidates.iter().map(|c| {
json!({
"name": c.name,
"file": c.file.display().to_string(),
"line": c.line
})
}).collect::<Vec<_>>()
});
if let Some(reason) = &sa.failure_reason {
obj["failure_reason"] = match reason {
FailureReason::NoCandidates => json!("NoCandidates"),
FailureReason::Ambiguous(ids) => json!({
"Ambiguous": ids.iter().map(|id| id.name.clone()).collect::<Vec<_>>()
}),
FailureReason::FilteredOut(reason) => json!({
"FilteredOut": reason
}),
FailureReason::NotApplicable => json!("NotApplicable"),
};
}
obj
}).collect::<Vec<_>>()
})
})
.collect();
let report = json!({
"statistics": {
"total_attempts": self.stats.total_attempts,
"resolved": self.stats.resolved,
"failed": self.stats.failed,
"success_rate": self.stats.success_rate() / 100.0,
"by_strategy": self.stats.by_strategy.iter().map(|(strategy, stats)| {
(format!("{:?}", strategy), json!({
"attempts": stats.attempts,
"successes": stats.successes,
"failures": stats.failures,
"avg_confidence": stats.avg_confidence
}))
}).collect::<serde_json::Map<String, serde_json::Value>>()
},
"failed_resolutions": failed_resolutions
});
serde_json::to_string_pretty(&report).unwrap_or_else(|_| "{}".to_string())
}
pub fn write_report<W: Write>(&self, writer: &mut W) -> std::io::Result<()> {
let report = match self.config.format {
DebugFormat::Text => self.generate_text_report(),
DebugFormat::Json => self.generate_json_report(),
};
write!(writer, "{}", report)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn test_debugger_creation() {
let config = DebugConfig::default();
let debugger = CallGraphDebugger::new(config);
assert_eq!(debugger.statistics().total_attempts, 0);
assert_eq!(debugger.statistics().resolved, 0);
assert_eq!(debugger.statistics().failed, 0);
}
#[test]
fn test_record_successful_attempt() {
let mut debugger = CallGraphDebugger::new(DebugConfig::default());
let caller = FunctionId::new(PathBuf::from("test.rs"), "caller".to_string(), 10);
let callee = FunctionId::new(PathBuf::from("test.rs"), "callee".to_string(), 20);
let attempt = ResolutionAttempt {
caller: caller.clone(),
callee_name: "callee".to_string(),
strategy_attempts: vec![StrategyAttempt {
strategy: ResolutionStrategy::Exact,
candidates: vec![callee.clone()],
failure_reason: None,
confidence: Some(1.0),
}],
result: Some(callee),
duration: Duration::from_millis(1),
};
debugger.record_attempt(attempt);
assert_eq!(debugger.statistics().total_attempts, 1);
assert_eq!(debugger.statistics().resolved, 1);
assert_eq!(debugger.statistics().failed, 0);
}
#[test]
fn test_record_failed_attempt() {
let mut debugger = CallGraphDebugger::new(DebugConfig::default());
let caller = FunctionId::new(PathBuf::from("test.rs"), "caller".to_string(), 10);
let attempt = ResolutionAttempt {
caller: caller.clone(),
callee_name: "unknown".to_string(),
strategy_attempts: vec![StrategyAttempt {
strategy: ResolutionStrategy::Exact,
candidates: vec![],
failure_reason: Some(FailureReason::NoCandidates),
confidence: None,
}],
result: None,
duration: Duration::from_millis(2),
};
debugger.record_attempt(attempt);
assert_eq!(debugger.statistics().total_attempts, 1);
assert_eq!(debugger.statistics().resolved, 0);
assert_eq!(debugger.statistics().failed, 1);
}
#[test]
fn test_success_rate_calculation() {
let mut stats = ResolutionStatistics::default();
assert_eq!(stats.success_rate(), 0.0);
stats.total_attempts = 100;
stats.resolved = 95;
stats.failed = 5;
assert!((stats.success_rate() - 95.0).abs() < 0.01);
}
#[test]
fn test_trace_function_filtering() {
let mut debugger = CallGraphDebugger::new(DebugConfig::default());
debugger.add_trace_function("specific_function".to_string());
assert!(debugger.should_trace("specific_function"));
assert!(debugger.should_trace("module::specific_function"));
assert!(!debugger.should_trace("other_function"));
}
#[test]
fn test_percentiles_calculation() {
let durations = vec![
Duration::from_millis(1),
Duration::from_millis(2),
Duration::from_millis(3),
Duration::from_millis(4),
Duration::from_millis(100),
];
let mut sorted = durations.clone();
sorted.sort();
let percentiles = Percentiles::from_sorted(&sorted);
assert_eq!(percentiles.p50, Duration::from_millis(3));
assert!(percentiles.p95.as_millis() >= 3);
}
}