use std::collections::{HashMap, HashSet};
use anyhow::{bail, Result};
use serde::{Deserialize, Serialize};
use crate::index::Embedder;
use crate::storage::{MetadataStore, VectorStore};
const MAX_DEPTH: u32 = 3;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ImpactSignal {
Coupling { co_changes: u32 },
Semantic { similarity: f32 },
Dependency,
Combined,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImpactResult {
pub path: String,
pub signal: ImpactSignal,
pub score: f32,
pub reason: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImpactMode {
Combined,
Coupling,
Semantic,
Deps,
}
pub struct ImpactConfig {
pub mode: ImpactMode,
pub threshold: f32,
pub limit: usize,
}
impl Default for ImpactConfig {
fn default() -> Self {
Self {
mode: ImpactMode::Combined,
threshold: 0.1,
limit: 15,
}
}
}
pub struct ImpactAnalyzer<'a> {
metadata_store: &'a mut MetadataStore,
vector_store: &'a mut VectorStore,
embedder: &'a mut Embedder,
}
impl<'a> ImpactAnalyzer<'a> {
pub fn new(
metadata_store: &'a mut MetadataStore,
vector_store: &'a mut VectorStore,
embedder: &'a mut Embedder,
) -> Self {
Self {
metadata_store,
vector_store,
embedder,
}
}
pub async fn analyze(
&mut self,
target: &str,
config: &ImpactConfig,
depth: u32,
repo: Option<&str>,
) -> Result<Vec<ImpactResult>> {
if config.mode == ImpactMode::Deps {
bail!("Dependency graph impact analysis is not yet available. This will be enabled when bobbin-graph lands.");
}
let depth = depth.clamp(1, MAX_DEPTH);
let decay_factor: f32 = 0.5;
let mut all_results: HashMap<String, ImpactResult> = HashMap::new();
let mut visited: HashSet<String> = HashSet::new();
let mut current_targets = vec![target.to_string()];
for level in 0..depth {
let decay = decay_factor.powi(level as i32);
let mut next_targets = Vec::new();
for t in ¤t_targets {
if !visited.insert(t.clone()) {
continue;
}
let results = self.analyze_single(t, config, repo).await?;
for mut r in results {
r.score *= decay;
if r.score < config.threshold {
continue;
}
next_targets.push(r.path.clone());
all_results
.entry(r.path.clone())
.and_modify(|existing| {
if r.score > existing.score {
*existing = r.clone();
}
})
.or_insert(r);
}
}
current_targets = next_targets;
if current_targets.is_empty() {
break;
}
}
let mut results: Vec<ImpactResult> = all_results.into_values().collect();
results.retain(|r| r.score >= config.threshold);
results.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
results.truncate(config.limit);
Ok(results)
}
async fn analyze_single(
&mut self,
target: &str,
config: &ImpactConfig,
repo: Option<&str>,
) -> Result<Vec<ImpactResult>> {
let (file_path, function_name) = parse_target(target);
let mut signal_map: HashMap<String, Vec<(ImpactSignal, f32, String)>> = HashMap::new();
if config.mode == ImpactMode::Coupling || config.mode == ImpactMode::Combined {
self.gather_coupling_signal(file_path, &mut signal_map, config)?;
}
if config.mode == ImpactMode::Semantic || config.mode == ImpactMode::Combined {
self.gather_semantic_signal(file_path, function_name, &mut signal_map, config, repo)
.await?;
}
let mut results = merge_signals(signal_map, config.mode);
results.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
Ok(results)
}
fn gather_coupling_signal(
&self,
file_path: &str,
signal_map: &mut HashMap<String, Vec<(ImpactSignal, f32, String)>>,
config: &ImpactConfig,
) -> Result<()> {
let couplings = self.metadata_store.get_coupling(file_path, config.limit)?;
if couplings.is_empty() {
return Ok(());
}
let max_score = couplings.iter().map(|c| c.score).fold(0.0f32, f32::max);
for coupling in &couplings {
let other = if coupling.file_a == file_path {
&coupling.file_b
} else {
&coupling.file_a
};
let normalized = if max_score > 0.0 {
coupling.score / max_score
} else {
0.0
};
let reason = format!(
"Co-changed {} times (coupling score {:.2})",
coupling.co_changes, coupling.score
);
signal_map.entry(other.clone()).or_default().push((
ImpactSignal::Coupling {
co_changes: coupling.co_changes,
},
normalized,
reason,
));
}
Ok(())
}
async fn gather_semantic_signal(
&mut self,
file_path: &str,
function_name: Option<&str>,
signal_map: &mut HashMap<String, Vec<(ImpactSignal, f32, String)>>,
config: &ImpactConfig,
repo: Option<&str>,
) -> Result<()> {
let chunks = self
.vector_store
.get_chunks_for_file(file_path, repo)
.await?;
if chunks.is_empty() {
return Ok(());
}
let target_content = if let Some(func) = function_name {
chunks
.iter()
.find(|c| c.name.as_deref() == Some(func))
.map(|c| c.content.as_str())
.unwrap_or_else(|| chunks[0].content.as_str())
} else {
chunks[0].content.as_str()
};
let embedding = self.embedder.embed(target_content).await?;
let search_limit = config.limit * 3;
let results = self
.vector_store
.search(&embedding, search_limit, repo)
.await?;
for result in &results {
if result.chunk.file_path == file_path {
continue;
}
let reason = format!(
"Semantically similar (score {:.3}, chunk: {})",
result.score,
result
.chunk
.name
.as_deref()
.unwrap_or(&result.chunk.chunk_type.to_string())
);
signal_map
.entry(result.chunk.file_path.clone())
.or_default()
.push((
ImpactSignal::Semantic {
similarity: result.score,
},
result.score,
reason,
));
}
Ok(())
}
}
fn parse_target(target: &str) -> (&str, Option<&str>) {
if let Some(idx) = target.rfind(':') {
let file = &target[..idx];
let func = &target[idx + 1..];
if !func.is_empty() && !func.contains('/') && !func.contains('\\') {
return (file, Some(func));
}
}
(target, None)
}
fn merge_signals(
signal_map: HashMap<String, Vec<(ImpactSignal, f32, String)>>,
mode: ImpactMode,
) -> Vec<ImpactResult> {
signal_map
.into_iter()
.map(|(path, signals)| {
if signals.len() == 1 || mode != ImpactMode::Combined {
let (signal, score, reason) = signals
.into_iter()
.next()
.expect("signal_map values are always non-empty");
ImpactResult {
path,
signal,
score,
reason,
}
} else {
let best = signals
.iter()
.max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
.expect("signals is non-empty in Combined branch");
let best_score = best.1;
let reasons: Vec<&str> = signals.iter().map(|(_, _, r)| r.as_str()).collect();
let combined_reason = reasons.join("; ");
ImpactResult {
path,
signal: ImpactSignal::Combined,
score: best_score,
reason: combined_reason,
}
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_target_file_only() {
let (file, func) = parse_target("src/auth.rs");
assert_eq!(file, "src/auth.rs");
assert_eq!(func, None);
}
#[test]
fn test_parse_target_file_and_function() {
let (file, func) = parse_target("src/auth.rs:validate_token");
assert_eq!(file, "src/auth.rs");
assert_eq!(func, Some("validate_token"));
}
#[test]
fn test_parse_target_no_false_split_on_path() {
let (file, func) = parse_target("src/auth/middleware.rs");
assert_eq!(file, "src/auth/middleware.rs");
assert_eq!(func, None);
}
#[test]
fn test_merge_signals_single() {
let mut map = HashMap::new();
map.insert(
"src/b.rs".to_string(),
vec![(
ImpactSignal::Coupling { co_changes: 5 },
0.8,
"Co-changed 5 times".to_string(),
)],
);
let results = merge_signals(map, ImpactMode::Coupling);
assert_eq!(results.len(), 1);
assert_eq!(results[0].path, "src/b.rs");
assert!((results[0].score - 0.8).abs() < f32::EPSILON);
}
#[test]
fn test_merge_signals_combined_takes_max() {
let mut map = HashMap::new();
map.insert(
"src/b.rs".to_string(),
vec![
(
ImpactSignal::Coupling { co_changes: 3 },
0.5,
"coupling".to_string(),
),
(
ImpactSignal::Semantic { similarity: 0.9 },
0.9,
"semantic".to_string(),
),
],
);
let results = merge_signals(map, ImpactMode::Combined);
assert_eq!(results.len(), 1);
assert!((results[0].score - 0.9).abs() < f32::EPSILON);
assert!(matches!(results[0].signal, ImpactSignal::Combined));
}
#[test]
fn test_threshold_filtering() {
let results = vec![
ImpactResult {
path: "a.rs".to_string(),
signal: ImpactSignal::Coupling { co_changes: 10 },
score: 0.9,
reason: "high".to_string(),
},
ImpactResult {
path: "b.rs".to_string(),
signal: ImpactSignal::Coupling { co_changes: 1 },
score: 0.05,
reason: "low".to_string(),
},
ImpactResult {
path: "c.rs".to_string(),
signal: ImpactSignal::Semantic { similarity: 0.15 },
score: 0.15,
reason: "medium".to_string(),
},
];
let threshold = 0.1;
let filtered: Vec<_> = results
.into_iter()
.filter(|r| r.score >= threshold)
.collect();
assert_eq!(filtered.len(), 2);
assert_eq!(filtered[0].path, "a.rs");
assert_eq!(filtered[1].path, "c.rs");
}
#[test]
fn test_deps_mode_returns_error() {
let mode = ImpactMode::Deps;
assert_eq!(mode, ImpactMode::Deps);
}
#[test]
fn test_impact_config_defaults() {
let config = ImpactConfig::default();
assert_eq!(config.mode, ImpactMode::Combined);
assert!((config.threshold - 0.1).abs() < f32::EPSILON);
assert_eq!(config.limit, 15);
}
#[test]
fn test_merge_signals_multiple_files() {
let mut map = HashMap::new();
map.insert(
"src/a.rs".to_string(),
vec![(
ImpactSignal::Coupling { co_changes: 10 },
1.0,
"high coupling".to_string(),
)],
);
map.insert(
"src/b.rs".to_string(),
vec![(
ImpactSignal::Semantic { similarity: 0.6 },
0.6,
"similar".to_string(),
)],
);
map.insert(
"src/c.rs".to_string(),
vec![
(
ImpactSignal::Coupling { co_changes: 2 },
0.3,
"some coupling".to_string(),
),
(
ImpactSignal::Semantic { similarity: 0.7 },
0.7,
"quite similar".to_string(),
),
],
);
let mut results = merge_signals(map, ImpactMode::Combined);
results.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap());
assert_eq!(results.len(), 3);
assert_eq!(results[0].path, "src/a.rs");
assert!((results[0].score - 1.0).abs() < f32::EPSILON);
let c_result = results.iter().find(|r| r.path == "src/c.rs").unwrap();
assert!((c_result.score - 0.7).abs() < f32::EPSILON);
assert!(matches!(c_result.signal, ImpactSignal::Combined));
}
#[test]
fn test_max_depth_cap() {
let clamped = 10u32.clamp(1, MAX_DEPTH);
assert_eq!(clamped, 3);
let clamped = 0u32.clamp(1, MAX_DEPTH);
assert_eq!(clamped, 1);
let clamped = 2u32.clamp(1, MAX_DEPTH);
assert_eq!(clamped, 2);
}
#[test]
fn test_decay_factor_math() {
let decay_factor: f32 = 0.5;
assert!((decay_factor.powi(0) - 1.0).abs() < f32::EPSILON);
assert!((decay_factor.powi(1) - 0.5).abs() < f32::EPSILON);
assert!((decay_factor.powi(2) - 0.25).abs() < f32::EPSILON);
}
#[test]
fn test_transitive_visited_set_prevents_cycles() {
let mut visited: HashSet<String> = HashSet::new();
assert!(visited.insert("src/a.rs".to_string()));
assert!(!visited.insert("src/a.rs".to_string()));
assert!(visited.insert("src/b.rs".to_string()));
}
#[test]
fn test_transitive_highest_score_kept() {
let mut all_results: HashMap<String, ImpactResult> = HashMap::new();
let r1 = ImpactResult {
path: "src/c.rs".to_string(),
signal: ImpactSignal::Coupling { co_changes: 5 },
score: 0.4,
reason: "transitive via b.rs".to_string(),
};
all_results.insert(r1.path.clone(), r1);
let r2 = ImpactResult {
path: "src/c.rs".to_string(),
signal: ImpactSignal::Semantic { similarity: 0.7 },
score: 0.7,
reason: "direct semantic".to_string(),
};
all_results
.entry(r2.path.clone())
.and_modify(|existing| {
if r2.score > existing.score {
*existing = r2.clone();
}
})
.or_insert(r2);
let result = &all_results["src/c.rs"];
assert!((result.score - 0.7).abs() < f32::EPSILON);
assert!(matches!(result.signal, ImpactSignal::Semantic { .. }));
}
#[test]
fn test_transitive_lower_score_not_replaced() {
let mut all_results: HashMap<String, ImpactResult> = HashMap::new();
let r1 = ImpactResult {
path: "src/c.rs".to_string(),
signal: ImpactSignal::Coupling { co_changes: 10 },
score: 0.9,
reason: "direct coupling".to_string(),
};
all_results.insert(r1.path.clone(), r1);
let r2 = ImpactResult {
path: "src/c.rs".to_string(),
signal: ImpactSignal::Semantic { similarity: 0.6 },
score: 0.3,
reason: "transitive semantic".to_string(),
};
all_results
.entry(r2.path.clone())
.and_modify(|existing| {
if r2.score > existing.score {
*existing = r2.clone();
}
})
.or_insert(r2);
let result = &all_results["src/c.rs"];
assert!((result.score - 0.9).abs() < f32::EPSILON);
assert!(matches!(result.signal, ImpactSignal::Coupling { .. }));
}
#[test]
fn test_transitive_decay_filters_below_threshold() {
let threshold = 0.1;
let decay: f32 = 0.5;
let decayed_score = 0.15 * decay;
assert!(decayed_score < threshold);
let decayed_score = 0.3 * decay;
assert!(decayed_score >= threshold);
}
}