use std::collections::HashSet;
use std::path::Path;
use anyhow::Result;
use async_trait::async_trait;
use serde::Deserialize;
use serde_json::json;
use super::{ApprovalRequirement, Tool, ToolContext, ToolDef, ToolResult};
pub struct BlastRadiusTool;
#[derive(Deserialize)]
struct BlastRadiusArgs {
file: String,
}
fn shorten_path(path: &Path) -> String {
let components: Vec<_> = path.components().collect();
if components.len() <= 3 {
return path.display().to_string();
}
let last3: Vec<_> = components[components.len() - 3..]
.iter()
.map(|c| c.as_os_str())
.collect();
format!(
".../{}",
last3
.iter()
.map(|s| s.to_string_lossy())
.collect::<Vec<_>>()
.join("/")
)
}
#[async_trait]
impl Tool for BlastRadiusTool {
fn definition(&self) -> ToolDef {
ToolDef {
name: "blast_radius",
description: "Estimate the blast radius of changing a file. Shows direct dependents \
(depth 1), indirect dependents (depth 2-3), and total impacted file count.\n\
Use before refactoring to understand the scope of changes.\n\
Example: {\"file\": \"src/tool/mod.rs\"}"
.to_string(),
parameters: json!({
"type": "object",
"properties": {
"file": { "type": "string", "description": "File path (relative to working dir or absolute)" }
},
"required": ["file"]
}),
}
}
fn approval(&self, _args: &str) -> ApprovalRequirement {
ApprovalRequirement::AutoApprove
}
fn approval_with_context(&self, args: &str, ctx: &ToolContext) -> ApprovalRequirement {
let parsed = match serde_json::from_str::<BlastRadiusArgs>(args) {
Ok(parsed) => parsed,
Err(_) => return self.approval(args),
};
let working_dir = match ctx.working_dir.try_read() {
Ok(wd) => wd.clone(),
Err(_) => return self.approval(args),
};
match super::approval_for_path(
&parsed.file,
&working_dir,
super::ExternalPathAction::Enumerate,
) {
Ok(approval) => approval,
Err(_) => self.approval(args),
}
}
async fn execute(&self, args: &str, ctx: &ToolContext) -> Result<ToolResult> {
let parsed: BlastRadiusArgs = serde_json::from_str(args)?;
let wd = ctx.working_dir.read().await.clone();
let file_path = match super::inspect_path_access(&parsed.file, &wd) {
Ok(access) => access.path,
Err(err) => {
return Ok(ToolResult {
call_id: String::new(),
output: err.to_string(),
success: false,
});
}
};
let graph = ctx.graph.read().await;
if !graph.is_ready() {
return Ok(ToolResult {
call_id: String::new(),
output: "Code graph is not yet indexed. The graph will be available after the \
background indexer completes. Try again shortly."
.to_string(),
success: false,
});
}
let symbols = match graph.symbols_in_file(&file_path) {
Some(ids) => ids.clone(),
None => {
return Ok(ToolResult {
call_id: String::new(),
output: format!(
"File '{}' not found in code graph. Check the path or wait for indexing.",
parsed.file
),
success: false,
});
}
};
let mut direct = HashSet::new();
for &sym_id in &symbols {
if let Some(edges) = graph.callers(sym_id) {
for edge in edges {
if let Some(node) = graph.node(edge.to) {
if node.file != file_path {
direct.insert(node.file.clone());
}
}
}
}
}
let all_dependents = graph.file_dependents(&file_path, 3);
let mut indirect = HashSet::new();
for dep in &all_dependents {
if !direct.contains(dep) {
indirect.insert(dep.clone());
}
}
let total = direct.len() + indirect.len();
let mut out = format!("Blast radius for {}:\n\n", shorten_path(&file_path));
out.push_str(&format!("DIRECT DEPENDENTS ({} files):\n", direct.len()));
if direct.is_empty() {
out.push_str(" (none)\n");
} else {
let mut sorted: Vec<_> = direct.iter().collect();
sorted.sort();
for f in sorted {
out.push_str(&format!(" {}\n", shorten_path(f)));
}
}
out.push_str(&format!(
"\nINDIRECT DEPENDENTS ({} files):\n",
indirect.len()
));
if indirect.is_empty() {
out.push_str(" (none)\n");
} else {
let mut sorted: Vec<_> = indirect.iter().collect();
sorted.sort();
for f in sorted {
out.push_str(&format!(" {}\n", shorten_path(f)));
}
}
out.push_str(&format!("\nTOTAL IMPACT: {} files\n", total));
Ok(ToolResult {
call_id: String::new(),
output: out,
success: true,
})
}
}