use std::path::{Path, PathBuf};
use super::{clean_regex_for_search, extract_search_query_from_bash, is_meaningful_search_query};
pub(crate) fn is_source_code_file(path: &str) -> bool {
let ext = Path::new(path)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
matches!(
ext,
"rs" | "go"
| "py"
| "ts"
| "tsx"
| "js"
| "jsx"
| "java"
| "c"
| "cpp"
| "h"
| "hpp"
| "cs"
| "rb"
| "swift"
| "kt"
| "scala"
| "zig"
| "lua"
| "ex"
| "exs"
| "erl"
| "hs"
| "ml"
| "mli"
| "fs"
| "fsi"
)
}
const BASH_STDOUT_SCAN_LIMIT: usize = 64 * 1024;
const MAX_DISCOVERED_FILES: usize = 5;
pub(crate) fn extract_files_from_tool_response(
tool_name: &str,
tool_response: &serde_json::Value,
) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
if let Some(arr) = tool_response.get("filenames").and_then(|v| v.as_array()) {
out.extend(
arr.iter()
.filter_map(|v| v.as_str())
.filter(|s| looks_like_discovered_path(s))
.map(|s| s.to_string()),
);
}
match tool_name {
"Grep" => {
if out.is_empty() {
if let Some(content) = tool_response.get("content").and_then(|v| v.as_str()) {
out.extend(content.lines().filter_map(match_line_path));
}
}
}
"Glob" => {
if out.is_empty() {
if let Some(arr) = tool_response.as_array() {
out.extend(
arr.iter()
.filter_map(|v| v.as_str())
.filter(|s| looks_like_discovered_path(s))
.map(|s| s.to_string()),
);
}
}
if out.is_empty() {
out.extend(newline_separated_paths(tool_response));
}
}
"Bash" => {
if let Some(stdout) = tool_response.get("stdout").and_then(|v| v.as_str()) {
for line in truncate_on_char_boundary(stdout, BASH_STDOUT_SCAN_LIMIT).lines() {
if let Some(path) = match_line_path(line) {
out.push(path);
continue;
}
out.extend(bare_path_tokens(line));
}
}
}
_ => {}
}
out
}
fn match_line_path(line: &str) -> Option<String> {
let (candidate, rest) = line.split_once(':')?;
let lineno = rest.split_once(':').map(|(n, _)| n).unwrap_or(rest);
if lineno.is_empty() || !lineno.chars().all(|c| c.is_ascii_digit()) {
return None;
}
let candidate = candidate.trim();
looks_like_discovered_path(candidate).then(|| candidate.to_string())
}
fn bare_path_tokens(line: &str) -> Vec<String> {
line.split_whitespace()
.map(|t| {
t.trim_matches(|c: char| matches!(c, '"' | '\'' | '`' | '(' | ')' | ',' | ';' | ':'))
})
.filter(|t| looks_like_discovered_path(t))
.map(|t| t.to_string())
.collect()
}
fn newline_separated_paths(tool_response: &serde_json::Value) -> Vec<String> {
let text = match tool_response {
serde_json::Value::String(s) => s.as_str(),
v => v.get("content").and_then(|c| c.as_str()).unwrap_or(""),
};
text.lines()
.map(str::trim)
.filter(|l| looks_like_discovered_path(l))
.map(|l| l.to_string())
.collect()
}
fn looks_like_discovered_path(token: &str) -> bool {
if token.is_empty() || token.len() > 512 {
return false;
}
if token.contains(['*', '?']) {
return false;
}
is_source_code_file(token)
}
fn truncate_on_char_boundary(s: &str, max: usize) -> &str {
if s.len() <= max {
return s;
}
let mut end = max;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
&s[..end]
}
pub(crate) fn resolve_discovered_files(
raw: Vec<String>,
cwd: &Path,
repo_root: &Path,
) -> Vec<String> {
let root = repo_root
.canonicalize()
.unwrap_or_else(|_| repo_root.to_path_buf());
let mut resolved: Vec<String> = Vec::new();
for candidate in raw {
let trimmed = candidate.strip_prefix("./").unwrap_or(&candidate);
let abs = if Path::new(trimmed).is_absolute() {
PathBuf::from(trimmed)
} else {
cwd.join(trimmed)
};
let Ok(abs) = abs.canonicalize() else {
continue;
};
if !abs.is_file() {
continue;
}
let Ok(rel) = abs.strip_prefix(&root) else {
continue;
};
let rel = rel.to_string_lossy().to_string();
if rel.is_empty() || resolved.contains(&rel) {
continue;
}
resolved.push(rel);
if resolved.len() >= MAX_DISCOVERED_FILES {
break;
}
}
resolved
}
pub(crate) const MAX_COUPLED_FILES: usize = 5;
const MIN_COUPLING_SCORE: f32 = 0.1;
pub(crate) fn merge_coupling_for_seeds<F>(seeds: &[String], mut lookup: F) -> Vec<(String, f32)>
where
F: FnMut(&str) -> Vec<crate::types::FileCoupling>,
{
let mut merged: Vec<(String, f32)> = Vec::new();
for seed in seeds {
for c in lookup(seed) {
if c.score < MIN_COUPLING_SCORE {
continue;
}
let other = if c.file_a == *seed {
c.file_b.clone()
} else {
c.file_a.clone()
};
if seeds.contains(&other) {
continue;
}
match merged.iter_mut().find(|(f, _)| *f == other) {
Some(existing) => existing.1 = existing.1.max(c.score),
None => merged.push((other, c.score)),
}
}
}
merged.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
merged.truncate(MAX_COUPLED_FILES);
merged
}
pub(crate) fn render_discovered_coupling(
discovered: &[String],
coupled: &[(String, f32)],
available: usize,
separate: bool,
) -> (String, usize) {
use std::fmt::Write;
let header_lines = if separate { 4 } else { 3 };
if coupled.is_empty() || available < header_lines + 1 {
return (String::new(), 0);
}
let mut out = String::new();
let mut used = 0;
if separate {
let _ = writeln!(out);
used += 1;
}
let found = discovered
.iter()
.map(|f| format!("`{}`", f))
.collect::<Vec<_>>()
.join(", ");
let _ = writeln!(out, "## Related to What You Found");
let _ = writeln!(
out,
"Your search matched {} — these co-change with them (from git history):\n",
found
);
used += 3;
for (file, score) in coupled {
if used >= available {
break;
}
let _ = writeln!(out, "- `{}` (coupling: {:.2})", file, score);
used += 1;
}
(out, used)
}
pub(crate) enum DispatchMode {
EditRelated {
file_path: String,
},
SearchQuery {
query: String,
original_cmd: String,
},
RefsOnly {
file_path: String,
},
ReactionsOnly, DiscoveredFiles {
files: Vec<String>,
query: String,
original_cmd: String,
},
}
pub(crate) fn classify_dispatch(tool_name: &str, tool_input: &serde_json::Value) -> DispatchMode {
match tool_name {
"Edit" | "Write" => {
let file_path = tool_input
.get("file_path")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if file_path.is_empty() {
DispatchMode::ReactionsOnly
} else {
DispatchMode::EditRelated { file_path }
}
}
"Bash" => {
let command = tool_input
.get("command")
.and_then(|v| v.as_str())
.unwrap_or("");
match extract_search_query_from_bash(command) {
Some(query) if is_meaningful_search_query(&query) => DispatchMode::SearchQuery {
query,
original_cmd: command.to_string(),
},
_ => DispatchMode::ReactionsOnly,
}
}
"Grep" => {
let pattern = tool_input
.get("pattern")
.and_then(|v| v.as_str())
.unwrap_or("");
if pattern.len() < 2 {
DispatchMode::ReactionsOnly
} else {
let cleaned = clean_regex_for_search(pattern);
if cleaned.is_empty() || !is_meaningful_search_query(&cleaned) {
DispatchMode::ReactionsOnly
} else {
DispatchMode::SearchQuery {
query: cleaned,
original_cmd: format!("Grep: {}", pattern),
}
}
}
}
"Glob" => {
let pattern = tool_input
.get("pattern")
.and_then(|v| v.as_str())
.unwrap_or("");
if pattern.len() < 2 {
DispatchMode::ReactionsOnly
} else {
let cleaned = pattern
.replace("**", " ")
.replace("*.", "")
.replace(".*", "")
.replace('*', " ")
.replace('/', " ")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
if cleaned.len() < 2 || !is_meaningful_search_query(&cleaned) {
DispatchMode::ReactionsOnly
} else {
DispatchMode::SearchQuery {
query: cleaned,
original_cmd: format!("Glob: {}", pattern),
}
}
}
}
"Read" => {
let file_path = tool_input
.get("file_path")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if file_path.is_empty() || !is_source_code_file(&file_path) {
DispatchMode::ReactionsOnly
} else {
DispatchMode::RefsOnly { file_path }
}
}
_ => DispatchMode::ReactionsOnly,
}
}
#[cfg(test)]
#[path = "hook_tool_response_tests.rs"]
mod hook_tool_response_tests;