use rmcp::model::{CallToolResult, ContentBlock, ErrorData as McpError};
use crate::cli::global::GlobalFlags;
use crate::exit;
use super::params::*;
use super::{
PatchloomService, exit_code_to_result, no_results, validate_content_size, validate_param_size,
};
fn parse_optional_lang(
lang: Option<&str>,
) -> Result<Option<crate::ast::Language>, Box<Result<CallToolResult, McpError>>> {
match lang {
Some(s) => match crate::ast::parse_lang_hint(s) {
Ok(parsed) => Ok(Some(parsed)),
Err(e) => {
let msg = crate::exit::agent_error_message(&e);
let body = serde_json::json!({
"ok": false,
"applied": false,
"error_kind": "invalid_input",
"error": msg,
});
Err(Box::new(exit_code_to_result(
exit::FAILURE,
&body.to_string(),
&msg,
)))
}
},
None => Ok(None),
}
}
pub(super) fn handle_ast_list(
svc: &PatchloomService,
p: AstListParams,
) -> Result<CallToolResult, McpError> {
svc.check_path(&p.path)?;
let cwd = svc.cwd().to_path_buf();
let target = cwd.join(&p.path);
let lang_hint = match parse_optional_lang(p.lang.as_deref()) {
Ok(lang) => lang,
Err(r) => return *r,
};
let kind_filter = crate::cmd::ast::parse_kind_filter(&p.kind)
.map_err(|e| McpError::invalid_params(crate::exit::agent_error_message(&e), None))?;
let mut results = Vec::new();
if target.is_file() {
let lang = lang_hint.unwrap_or_else(|| crate::ast::Language::from_path(&target));
if !lang.has_grammar() {
return Err(McpError::invalid_params(
format!(
"Unsupported language: {} (detected from {}). \
Supported: Rust, Python, TypeScript, JavaScript, Go, Java, \
C#, Ruby, PHP, Swift, Kotlin, C, C++, HCL, XML, Protobuf, \
TOML, YAML, JSON, Shell.",
lang, p.path,
),
None,
));
}
let source = crate::files::load_text_strict(&target, &p.path).map_err(|e| {
if crate::exit::is_load_text_strict_fail(&e) || crate::exit::is_io_not_found(&e) {
McpError::invalid_params(e.to_string(), None)
} else {
McpError::internal_error(e.to_string(), None)
}
})?;
let symbols = match crate::ast::symbols::try_extract_symbols(&source, lang) {
Ok(s) => s,
Err(crate::ast::ParseFailure::DeadlineExceeded) => {
let msg = format!("parse deadline exceeded for {}", p.path);
let body = serde_json::json!({
"ok": false,
"applied": false,
"error_kind": "parse_timeout",
"error": msg,
});
return exit_code_to_result(exit::PARSE_ERROR, &body.to_string(), &msg);
}
Err(crate::ast::ParseFailure::NoGrammar) => Vec::new(),
};
let filtered = crate::cmd::ast::filter_symbols(&symbols, &kind_filter);
if !filtered.is_empty() {
for sym in &filtered {
results.push(crate::cmd::ast::symbol_to_json(sym, &p.path));
}
}
} else if target.is_dir() {
let global = GlobalFlags::with_cwd(&cwd);
let paths = crate::cmd::ast::collect_source_files(&target, &global)
.map_err(|e| McpError::internal_error(format!("{e}"), None))?;
struct ListResult {
entries: Vec<serde_json::Value>,
}
let timeout: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
let par_results: Vec<ListResult> = crate::par_process_files(&paths, None, &[], |path| {
let lang = lang_hint.unwrap_or_else(|| crate::ast::Language::from_path(path));
let symbols = match crate::ast::symbols::try_extract_symbols_from_file(path, Some(lang))
{
Ok(s) => s,
Err(crate::ast::ParseFailure::DeadlineExceeded) => {
let mut slot = timeout.lock().unwrap_or_else(|e| e.into_inner());
if slot.is_none() {
*slot = Some(path.display().to_string());
}
return None;
}
Err(crate::ast::ParseFailure::NoGrammar) => Vec::new(),
};
let filtered = crate::cmd::ast::filter_symbols(&symbols, &kind_filter);
if filtered.is_empty() {
return None;
}
let display = crate::cmd::ast::display_path(path, &cwd);
let entries = filtered
.iter()
.map(|sym| crate::cmd::ast::symbol_to_json(sym, &display))
.collect();
Some(ListResult { entries })
});
if let Some(file) = timeout.into_inner().unwrap_or_else(|e| e.into_inner()) {
let msg = format!("parse deadline exceeded for {file}");
let body = serde_json::json!({
"ok": false,
"applied": false,
"error_kind": "parse_timeout",
"error": msg,
});
return exit_code_to_result(exit::PARSE_ERROR, &body.to_string(), &msg);
}
for r in par_results {
results.extend(r.entries);
}
if results.is_empty()
&& let Some(err) = crate::ops::file::empty_scan_masked_by_unreadable(&paths, &cwd)
{
return Err(McpError::invalid_params(err.msg, None));
}
} else {
return Err(McpError::invalid_params(
format!("path not found: {}", p.path),
None,
));
}
if results.is_empty() {
return no_results("No symbols found.");
}
let json = serde_json::to_string_pretty(&results)
.map_err(|e| McpError::internal_error(format!("{e}"), None))?;
Ok(CallToolResult::success(vec![ContentBlock::text(json)]))
}
pub(super) fn handle_ast_read(
svc: &PatchloomService,
p: AstReadParams,
) -> Result<CallToolResult, McpError> {
svc.check_path(&p.path)?;
validate_param_size("symbol", &p.symbol)?;
let cwd = svc.cwd().to_path_buf();
let target = cwd.join(&p.path);
let lang_hint = match parse_optional_lang(p.lang.as_deref()) {
Ok(lang) => lang,
Err(r) => return *r,
};
let lang = lang_hint.unwrap_or_else(|| crate::ast::Language::from_path(&target));
let source = crate::files::load_text_strict(&target, &p.path).map_err(|e| {
if crate::exit::is_load_text_strict_fail(&e) || crate::exit::is_io_not_found(&e) {
McpError::invalid_params(e.to_string(), None)
} else {
McpError::internal_error(e.to_string(), None)
}
})?;
if !lang.has_grammar() {
return Err(McpError::invalid_params(
format!(
"Unsupported language: {} (detected from {}). \
Supported: Rust, Python, TypeScript, JavaScript, Go, Java, \
C#, Ruby, PHP, Swift, Kotlin, C, C++, HCL, XML, Protobuf, \
TOML, YAML, JSON, Shell.",
lang, p.path,
),
None,
));
}
let all_symbols = match crate::ast::symbols::try_extract_symbols(&source, lang) {
Ok(s) => s,
Err(crate::ast::ParseFailure::DeadlineExceeded) => {
let msg = format!("parse deadline exceeded for {}", p.path);
let body = serde_json::json!({
"ok": false,
"applied": false,
"error_kind": "parse_timeout",
"error": msg,
});
return exit_code_to_result(exit::PARSE_ERROR, &body.to_string(), &msg);
}
Err(crate::ast::ParseFailure::NoGrammar) => Vec::new(),
};
let Some(sym) = crate::ast::symbols::find_symbol(&all_symbols, &p.symbol) else {
let msg = format!("symbol '{}' not found in {}", p.symbol, p.path);
let body = serde_json::json!({
"ok": false,
"error_kind": "no_matches",
"error": msg,
"applied": false,
});
return exit_code_to_result(exit::NO_MATCHES, &body.to_string(), &msg);
};
let lines: Vec<&str> = crate::ops::file::text_lines(&source).collect();
let start = sym
.start_line
.saturating_sub(1_usize.saturating_add(p.context));
let end = sym.end_line.saturating_add(p.context).min(lines.len());
let content: String = lines[start..end].iter().map(|l| format!("{l}\n")).collect();
let obj = serde_json::json!({
"file": p.path,
"symbol": sym.name,
"kind": sym.kind.to_string(),
"start_line": sym.start_line,
"end_line": sym.end_line,
"signature": sym.signature,
"content": content,
});
let json = serde_json::to_string_pretty(&obj)
.map_err(|e| McpError::internal_error(format!("{e}"), None))?;
Ok(CallToolResult::success(vec![ContentBlock::text(json)]))
}
pub(super) fn handle_ast_rename(
svc: &PatchloomService,
p: AstRenameParams,
) -> Result<CallToolResult, McpError> {
svc.check_path(&p.path)?;
validate_param_size("old", &p.old)?;
validate_param_size("new", &p.new)?;
if let Err(e) = crate::ast::rename::reject_empty_rename_names(&p.old, &p.new) {
let msg = crate::exit::agent_error_message(&e);
let body = serde_json::json!({
"ok": false,
"applied": false,
"error_kind": "invalid_input",
"error": msg,
});
return exit_code_to_result(exit::FAILURE, &body.to_string(), &msg);
}
if p.old == p.new {
return exit_code_to_result(exit::NO_MATCHES, "", "old and new are identical.");
}
let cwd = svc.cwd().to_path_buf();
let target = cwd.join(&p.path);
let lang_hint = match parse_optional_lang(p.lang.as_deref()) {
Ok(lang) => lang,
Err(r) => return *r,
};
let global = GlobalFlags::with_cwd_and_json(&cwd);
let paths = crate::cmd::ast::resolve_target_paths(&target, &p.path, &global)
.map_err(|e| McpError::invalid_params(format!("{e}"), None))?;
if crate::cmd::ast::is_sole_explicit_file(&paths, &p.path) {
let sole = &paths[0];
if let Err(e) = crate::files::load_text_strict(sole, &p.path)
&& (crate::exit::is_load_text_strict_fail(&e) || crate::exit::is_io_not_found(&e))
{
return Err(McpError::invalid_params(e.to_string(), None));
}
}
for path in &paths {
let rel = path
.strip_prefix(&cwd)
.unwrap_or(path)
.to_string_lossy()
.into_owned();
svc.check_path(&rel)?;
}
let old = p.old.as_str();
let new = p.new.as_str();
let lang_cli = p.lang.clone();
let rename_op = |path: &std::path::Path| -> crate::plan::Operation {
let rel = path
.strip_prefix(&cwd)
.unwrap_or(path)
.to_string_lossy()
.into_owned();
crate::plan::Operation::AstRename {
path: rel,
old: old.to_string(),
new: new.to_string(),
lang: lang_cli.clone(),
}
};
let unreadable = std::sync::Mutex::new(Vec::<String>::new());
let operations: Vec<crate::plan::Operation> =
if crate::cmd::ast::is_sole_explicit_file(&paths, &p.path) {
let sole = &paths[0];
match crate::files::try_read_text_file(sole) {
Ok(source) => {
let lang = lang_hint.unwrap_or_else(|| crate::ast::Language::from_path(sole));
match crate::ast::rename::source_has_rename_match(&source, old, new, lang) {
Err(e) if crate::exit::is_parse_timeout(&e) => {
let msg = crate::exit::agent_error_message(&e);
let body = serde_json::json!({
"ok": false,
"applied": false,
"error_kind": "parse_timeout",
"error": msg,
});
return exit_code_to_result(exit::PARSE_ERROR, &body.to_string(), &msg);
}
Err(e) => {
return Err(McpError::invalid_params(
crate::exit::agent_error_message(&e),
None,
));
}
Ok(true) => vec![rename_op(sole)],
Ok(false) => Vec::new(),
}
}
Err(
crate::files::SoftTextSkip::Binary
| crate::files::SoftTextSkip::InvalidUtf8
| crate::files::SoftTextSkip::NotRegularFile,
) => Vec::new(),
Err(crate::files::SoftTextSkip::Unreadable) => {
if let Ok(mut g) = unreadable.lock()
&& g.len() < 8
{
g.push(sole.display().to_string());
}
Vec::new()
}
}
} else {
crate::par_process_files(&paths, None, &[], |path| {
let source = match crate::files::try_read_text_file(path) {
Ok(s) => s,
Err(
crate::files::SoftTextSkip::Binary
| crate::files::SoftTextSkip::InvalidUtf8
| crate::files::SoftTextSkip::NotRegularFile,
) => {
return None;
}
Err(crate::files::SoftTextSkip::Unreadable) => {
if let Ok(mut g) = unreadable.lock()
&& g.len() < 8
{
g.push(path.display().to_string());
}
return None;
}
};
let lang = lang_hint.unwrap_or_else(|| crate::ast::Language::from_path(path));
let has_match = if lang.has_grammar() {
crate::ast::rename::rename_in_source(&source, old, new, lang)
.is_some_and(|r| r.replacements > 0)
} else {
false
} || {
crate::ops::replace::compile_replace_regex(old, false, false, false, true)
.ok()
.flatten()
.is_some_and(|re| re.is_match(&source))
};
if !has_match {
return None;
}
Some(rename_op(path))
})
};
if operations.is_empty() {
let unread = unreadable.into_inner().unwrap_or_default();
if !unread.is_empty() {
let sample = unread.join(", ");
return Err(McpError::invalid_params(
format!(
"could not read {} path(s) while scanning {} (e.g. {}); \
not reporting as no matches",
unread.len(),
p.path,
sample
),
None,
));
}
let body = serde_json::json!({
"ok": false,
"error_kind": "no_matches",
"error": "No matches found.",
"applied": false,
});
return exit_code_to_result(exit::NO_MATCHES, &body.to_string(), "No matches found.");
}
svc.run_ops(operations, None)
}
pub(super) fn handle_ast_validate(
svc: &PatchloomService,
p: AstValidateParams,
) -> Result<CallToolResult, McpError> {
svc.check_path(&p.path)?;
let cwd = svc.cwd().to_path_buf();
let target = cwd.join(&p.path);
let lang_hint = match parse_optional_lang(p.lang.as_deref()) {
Ok(lang) => lang,
Err(r) => return *r,
};
let global = GlobalFlags::with_cwd(&cwd);
let paths = crate::cmd::ast::resolve_target_paths(&target, &p.path, &global)
.map_err(|e| McpError::invalid_params(format!("{e}"), None))?;
if paths.len() == 1 {
let lang = lang_hint.unwrap_or_else(|| crate::ast::Language::from_path(&paths[0]));
if !lang.has_grammar() {
return Err(McpError::invalid_params(
format!(
"Unsupported language: {} (detected from {}). \
Supported: Rust, Python, TypeScript, JavaScript, Go, Java, \
C#, Ruby, PHP, Swift, Kotlin, C, C++, HCL, XML, Protobuf, \
TOML, YAML, JSON, Shell.",
lang, p.path,
),
None,
));
}
}
for path in &paths {
let lang = lang_hint.unwrap_or_else(|| crate::ast::Language::from_path(path));
if !lang.has_grammar() {
continue;
}
let display = crate::cmd::ast::display_path(path, &cwd);
if let Err(e) = crate::files::load_text_strict(path, &display)
&& (crate::exit::is_load_text_strict_fail(&e) || crate::exit::is_io_not_found(&e))
{
return Err(McpError::invalid_params(e.to_string(), None));
}
}
let results: Vec<serde_json::Value> = if paths.len() == 1 {
let path = &paths[0];
let lang = lang_hint.unwrap_or_else(|| crate::ast::Language::from_path(path));
match crate::ast::validate::validate_file(path, Some(lang)) {
Ok(result) => {
let display = crate::cmd::ast::display_path(path, &cwd);
vec![serde_json::json!({
"file": display,
"valid": result.valid,
"language": result.language,
"errors": result.errors,
})]
}
Err(e) if crate::exit::is_parse_timeout(&e) => {
let msg = crate::exit::agent_error_message(&e);
let body = serde_json::json!({
"ok": false,
"applied": false,
"error_kind": "parse_timeout",
"error": msg,
});
return exit_code_to_result(exit::PARSE_ERROR, &body.to_string(), &msg);
}
Err(e) => return Err(McpError::invalid_params(e.to_string(), None)),
}
} else {
crate::par_process_files(&paths, None, &[], |path| {
let lang = lang_hint.unwrap_or_else(|| crate::ast::Language::from_path(path));
if !lang.has_grammar() {
return None;
}
let result = crate::ast::validate::validate_file_for_walk(path, Some(lang))?;
let display = crate::cmd::ast::display_path(path, &cwd);
Some(serde_json::json!({
"file": display,
"valid": result.valid,
"language": result.language,
"errors": result.errors,
}))
})
};
if let Some(err) = crate::ops::file::empty_scan_masked_by_unreadable(&paths, &cwd) {
return Err(McpError::invalid_params(err.msg, None));
}
if results.is_empty() {
return Err(McpError::invalid_params(
"No files with grammars found.",
None,
));
}
let any_invalid = results
.iter()
.any(|r| r.get("valid") == Some(&serde_json::json!(false)));
let json = serde_json::to_string_pretty(&results)
.map_err(|e| McpError::internal_error(format!("{e}"), None))?;
if any_invalid {
Ok(CallToolResult::error(vec![ContentBlock::text(json)]))
} else {
Ok(CallToolResult::success(vec![ContentBlock::text(json)]))
}
}
pub(super) fn handle_ast_search(
svc: &PatchloomService,
p: AstSearchParams,
) -> Result<CallToolResult, McpError> {
svc.check_path(&p.path)?;
validate_param_size("query", &p.query)?;
let cwd = svc.cwd().to_path_buf();
let target = cwd.join(&p.path);
let lang_hint = match parse_optional_lang(p.lang.as_deref()) {
Ok(lang) => lang,
Err(r) => return *r,
};
let global = GlobalFlags::with_cwd(&cwd);
let paths = crate::cmd::ast::resolve_target_paths(&target, &p.path, &global)
.map_err(|e| McpError::invalid_params(format!("{e}"), None))?;
if crate::cmd::ast::is_sole_explicit_file(&paths, &p.path) {
let sole = &paths[0];
if let Err(e) = crate::files::load_text_strict(sole, &p.path)
&& (crate::exit::is_load_text_strict_fail(&e) || crate::exit::is_io_not_found(&e))
{
return Err(McpError::invalid_params(e.to_string(), None));
}
}
struct SearchFileResult {
entries: Vec<serde_json::Value>,
}
let precompiled_query = if p.pattern {
let validation_lang = lang_hint.unwrap_or_else(|| {
paths
.iter()
.find(|path| crate::ast::Language::from_path(path).has_grammar())
.map(|path| crate::ast::Language::from_path(path))
.unwrap_or(crate::ast::Language::Rust)
});
Some(
match crate::ast::search::compile_pattern_query(&p.query, validation_lang) {
Ok(q) => q,
Err(e) if crate::exit::is_invalid_input(&e) => {
let msg = crate::exit::agent_error_message(&e);
let body = serde_json::json!({
"ok": false,
"applied": false,
"error_kind": "invalid_input",
"error": msg,
});
return exit_code_to_result(exit::FAILURE, &body.to_string(), &msg);
}
Err(e) => {
return Err(McpError::invalid_params(
format!("invalid pattern query: {e}"),
None,
));
}
},
)
} else {
None
};
let search_query_for = |path: &std::path::Path| -> String {
if p.pattern {
let lang = lang_hint.unwrap_or_else(|| crate::ast::Language::from_path(path));
crate::ast::search::compile_pattern_query(&p.query, lang)
.unwrap_or_else(|_| precompiled_query.clone().unwrap_or_default())
} else {
p.query.clone()
}
};
let search_timeout_result = |e: &anyhow::Error| -> Result<CallToolResult, McpError> {
let msg = crate::exit::agent_error_message(e);
let body = serde_json::json!({
"ok": false,
"applied": false,
"error_kind": "parse_timeout",
"error": msg,
});
exit_code_to_result(exit::PARSE_ERROR, &body.to_string(), &msg)
};
if crate::cmd::ast::is_sole_explicit_file(&paths, &p.path) {
let path = &paths[0];
let lang = lang_hint.unwrap_or_else(|| crate::ast::Language::from_path(path));
let query_str = search_query_for(path);
match crate::ast::search::search_file(path, &query_str, Some(lang), p.max_results) {
Ok(results) => {
if results.is_empty() {
if let Some(err) =
crate::ops::file::empty_scan_masked_by_unreadable(&paths, &cwd)
{
return Err(McpError::invalid_params(err.msg, None));
}
return no_results("No matches found.");
}
let display = crate::cmd::ast::display_path(path, &cwd);
let all_matches: Vec<serde_json::Value> = results
.iter()
.map(|m| {
serde_json::json!({
"file": display,
"line": m.line,
"column": m.column,
"text": m.text,
"captures": m.captures,
})
})
.collect();
let json = serde_json::to_string_pretty(&all_matches)
.map_err(|e| McpError::internal_error(format!("{e}"), None))?;
return Ok(CallToolResult::success(vec![ContentBlock::text(json)]));
}
Err(e) if crate::exit::is_parse_timeout(&e) => {
return search_timeout_result(&e);
}
Err(e) if crate::exit::is_parse_error(&e) => {
return Err(McpError::invalid_params(
crate::exit::agent_error_message(&e),
None,
));
}
Err(e) => return Err(McpError::invalid_params(e.to_string(), None)),
}
}
if let Some(sample) = paths.iter().find(|path| {
lang_hint
.unwrap_or_else(|| crate::ast::Language::from_path(path))
.has_grammar()
}) {
let lang = lang_hint.unwrap_or_else(|| crate::ast::Language::from_path(sample));
let query_str = search_query_for(sample);
if let Err(e) = crate::ast::search::search_file(sample, &query_str, Some(lang), Some(1)) {
if crate::exit::is_parse_timeout(&e) {
return search_timeout_result(&e);
}
if crate::exit::is_parse_error(&e) {
return Err(McpError::invalid_params(
crate::exit::agent_error_message(&e),
None,
));
}
}
}
let par_results: Vec<SearchFileResult> = crate::par_process_files(&paths, None, &[], |path| {
let lang = lang_hint.unwrap_or_else(|| crate::ast::Language::from_path(path));
let query_str = search_query_for(path);
let results =
crate::ast::search::search_file(path, &query_str, Some(lang), p.max_results).ok()?;
if results.is_empty() {
return None;
}
let display = crate::cmd::ast::display_path(path, &cwd);
let entries = results
.iter()
.map(|m| {
serde_json::json!({
"file": display,
"line": m.line,
"column": m.column,
"text": m.text,
"captures": m.captures,
})
})
.collect();
Some(SearchFileResult { entries })
});
let all_matches: Vec<serde_json::Value> =
par_results.into_iter().flat_map(|r| r.entries).collect();
if all_matches.is_empty() {
if let Some(err) = crate::ops::file::empty_scan_masked_by_unreadable(&paths, &cwd) {
return Err(McpError::invalid_params(err.msg, None));
}
return no_results("No matches found.");
}
let json = serde_json::to_string_pretty(&all_matches)
.map_err(|e| McpError::internal_error(format!("{e}"), None))?;
Ok(CallToolResult::success(vec![ContentBlock::text(json)]))
}
pub(super) fn handle_ast_refs(
svc: &PatchloomService,
p: AstRefsParams,
) -> Result<CallToolResult, McpError> {
svc.check_path(&p.path)?;
validate_param_size("symbol", &p.symbol)?;
let cwd = svc.cwd().to_path_buf();
let target = cwd.join(&p.path);
let lang_hint = match parse_optional_lang(p.lang.as_deref()) {
Ok(lang) => lang,
Err(r) => return *r,
};
let global = GlobalFlags::with_cwd(&cwd);
let paths = crate::cmd::ast::resolve_target_paths(&target, &p.path, &global)
.map_err(|e| McpError::invalid_params(format!("{e}"), None))?;
let mut all_refs = if crate::cmd::ast::is_sole_explicit_file(&paths, &p.path) {
let sole = &paths[0];
let source = match crate::files::load_text_strict(sole, &p.path) {
Ok(s) => s,
Err(e)
if crate::exit::is_load_text_strict_fail(&e)
|| crate::exit::is_io_not_found(&e) =>
{
return Err(McpError::invalid_params(
crate::exit::agent_error_message(&e),
None,
));
}
Err(e) => {
return Err(McpError::internal_error(
crate::exit::agent_error_message(&e),
None,
));
}
};
let display = crate::cmd::ast::display_path(sole, &cwd);
let lang = lang_hint.unwrap_or_else(|| crate::ast::Language::from_path(sole));
match crate::ast::refs::try_find_refs_in_source(&source, &p.symbol, lang, &display) {
Ok(refs) => refs,
Err(crate::ast::ParseFailure::DeadlineExceeded) => {
let msg = format!("parse deadline exceeded for {}", p.path);
let body = serde_json::json!({
"ok": false,
"applied": false,
"error_kind": "parse_timeout",
"error": msg,
});
return exit_code_to_result(exit::PARSE_ERROR, &body.to_string(), &msg);
}
Err(crate::ast::ParseFailure::NoGrammar) => Vec::new(),
}
} else {
let timeout: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
let per_file: Vec<Vec<crate::ast::refs::SymbolRef>> =
crate::par_process_files(&paths, None, &[], |path| {
let display = crate::cmd::ast::display_path(path, &cwd);
let refs = match crate::ast::refs::try_find_refs_in_file(
path, &p.symbol, lang_hint, &display,
) {
Ok(refs) => refs,
Err(crate::ast::ParseFailure::DeadlineExceeded) => {
let mut slot = timeout.lock().unwrap_or_else(|e| e.into_inner());
if slot.is_none() {
*slot = Some(path.display().to_string());
}
return None;
}
Err(crate::ast::ParseFailure::NoGrammar) => Vec::new(),
};
if refs.is_empty() { None } else { Some(refs) }
});
if let Some(file) = timeout.into_inner().unwrap_or_else(|e| e.into_inner()) {
let msg = format!("parse deadline exceeded for {file}");
let body = serde_json::json!({
"ok": false,
"applied": false,
"error_kind": "parse_timeout",
"error": msg,
});
return exit_code_to_result(exit::PARSE_ERROR, &body.to_string(), &msg);
}
per_file.into_iter().flatten().collect()
};
if !p.include_def {
all_refs.retain(|r| r.kind != crate::ast::refs::RefKind::Definition);
}
if all_refs.is_empty() {
if let Some(err) = crate::ops::file::empty_scan_masked_by_unreadable(&paths, &cwd) {
return Err(McpError::invalid_params(err.msg, None));
}
return no_results("No references found.");
}
let obj = serde_json::json!({
"symbol": p.symbol,
"references": all_refs,
"count": all_refs.len(),
});
let json = serde_json::to_string_pretty(&obj)
.map_err(|e| McpError::internal_error(format!("{e}"), None))?;
Ok(CallToolResult::success(vec![ContentBlock::text(json)]))
}
pub(super) fn handle_ast_deps(
svc: &PatchloomService,
p: AstDepsParams,
) -> Result<CallToolResult, McpError> {
svc.check_path(&p.path)?;
let cwd = svc.cwd().to_path_buf();
let target = cwd.join(&p.path);
let lang_hint = match parse_optional_lang(p.lang.as_deref()) {
Ok(lang) => lang,
Err(r) => return *r,
};
let global = GlobalFlags::with_cwd(&cwd);
let paths = crate::cmd::ast::resolve_target_paths(&target, &p.path, &global)
.map_err(|e| McpError::invalid_params(format!("{e}"), None))?;
if crate::cmd::ast::is_sole_explicit_file(&paths, &p.path) {
let sole = &paths[0];
if let Err(e) = crate::files::load_text_strict(sole, &p.path)
&& (crate::exit::is_load_text_strict_fail(&e) || crate::exit::is_io_not_found(&e))
{
return Err(McpError::invalid_params(
crate::exit::agent_error_message(&e),
None,
));
}
}
if crate::cmd::ast::is_sole_explicit_file(&paths, &p.path) && !p.reverse {
let sole = &paths[0];
let source = crate::files::load_text_strict(sole, &p.path).map_err(|e| {
if crate::exit::is_load_text_strict_fail(&e) || crate::exit::is_io_not_found(&e) {
McpError::invalid_params(e.to_string(), None)
} else {
McpError::internal_error(e.to_string(), None)
}
})?;
let lang = lang_hint.unwrap_or_else(|| crate::ast::Language::from_path(sole));
let imports = match crate::ast::deps::try_extract_imports(&source, lang) {
Ok(i) => i,
Err(crate::ast::ParseFailure::DeadlineExceeded) => {
let msg = format!("parse deadline exceeded for {}", p.path);
let body = serde_json::json!({
"ok": false,
"applied": false,
"error_kind": "parse_timeout",
"error": msg,
});
return exit_code_to_result(exit::PARSE_ERROR, &body.to_string(), &msg);
}
Err(crate::ast::ParseFailure::NoGrammar) => Vec::new(),
};
if imports.is_empty() {
return no_results("No imports found.");
}
let display = crate::cmd::ast::display_path(sole, &cwd);
let results = vec![serde_json::json!({
"file": display,
"imports": imports,
})];
let json = serde_json::to_string_pretty(&results)
.map_err(|e| McpError::internal_error(format!("{e}"), None))?;
return Ok(CallToolResult::success(vec![ContentBlock::text(json)]));
}
let mut results = Vec::new();
if p.reverse {
let target_name = target
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or_default()
.to_string();
let all_files = crate::cmd::ast::collect_source_files(&cwd, &global)
.map_err(|e| McpError::internal_error(format!("{e}"), None))?;
struct RevDepsResult {
entries: Vec<serde_json::Value>,
}
let timeout: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
let par_results: Vec<RevDepsResult> =
crate::par_process_files(&all_files, None, &[], |path| {
let imports = match crate::ast::deps::try_extract_imports_from_file(path, lang_hint)
{
Ok(i) => i,
Err(crate::ast::ParseFailure::DeadlineExceeded) => {
let mut slot = timeout.lock().unwrap_or_else(|e| e.into_inner());
if slot.is_none() {
*slot = Some(path.display().to_string());
}
return None;
}
Err(crate::ast::ParseFailure::NoGrammar) => Vec::new(),
};
let matching: Vec<_> = imports
.iter()
.filter(|i| crate::ast::deps::import_path_refers_to_stem(&i.path, &target_name))
.collect();
if matching.is_empty() {
return None;
}
let display = crate::cmd::ast::display_path(path, &cwd);
let entries = matching
.iter()
.map(|imp| {
serde_json::json!({
"file": display,
"imports": imp.path,
"line": imp.line,
"raw": imp.raw,
})
})
.collect();
Some(RevDepsResult { entries })
});
if let Some(file) = timeout.into_inner().unwrap_or_else(|e| e.into_inner()) {
let msg = format!("parse deadline exceeded for {file}");
let body = serde_json::json!({
"ok": false,
"applied": false,
"error_kind": "parse_timeout",
"error": msg,
});
return exit_code_to_result(exit::PARSE_ERROR, &body.to_string(), &msg);
}
for r in par_results {
results.extend(r.entries);
}
if results.is_empty()
&& let Some(err) = crate::ops::file::empty_scan_masked_by_unreadable(&all_files, &cwd)
{
let msg = err.msg;
let body = serde_json::json!({
"ok": false,
"applied": false,
"error_kind": "invalid_input",
"error": msg,
});
return exit_code_to_result(exit::FAILURE, &body.to_string(), &msg);
}
} else {
let timeout: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
let par_results: Vec<serde_json::Value> =
crate::par_process_files(&paths, None, &[], |path| {
let imports = match crate::ast::deps::try_extract_imports_from_file(path, lang_hint)
{
Ok(i) => i,
Err(crate::ast::ParseFailure::DeadlineExceeded) => {
let mut slot = timeout.lock().unwrap_or_else(|e| e.into_inner());
if slot.is_none() {
*slot = Some(path.display().to_string());
}
return None;
}
Err(crate::ast::ParseFailure::NoGrammar) => Vec::new(),
};
if imports.is_empty() {
return None;
}
let display = crate::cmd::ast::display_path(path, &cwd);
Some(serde_json::json!({
"file": display,
"imports": imports,
}))
});
if let Some(file) = timeout.into_inner().unwrap_or_else(|e| e.into_inner()) {
let msg = format!("parse deadline exceeded for {file}");
let body = serde_json::json!({
"ok": false,
"applied": false,
"error_kind": "parse_timeout",
"error": msg,
});
return exit_code_to_result(exit::PARSE_ERROR, &body.to_string(), &msg);
}
results.extend(par_results);
}
if results.is_empty() {
if let Some(err) = crate::ops::file::empty_scan_masked_by_unreadable(&paths, &cwd) {
return Err(McpError::invalid_params(err.msg, None));
}
return no_results("No imports found.");
}
let json = serde_json::to_string_pretty(&results)
.map_err(|e| McpError::internal_error(format!("{e}"), None))?;
Ok(CallToolResult::success(vec![ContentBlock::text(json)]))
}
pub(super) fn handle_ast_map(
svc: &PatchloomService,
p: AstMapParams,
) -> Result<CallToolResult, McpError> {
svc.check_path(&p.path)?;
for s in &p.focus {
validate_param_size("focus", s)?;
}
for s in &p.boost {
validate_param_size("boost", s)?;
}
let cwd = svc.cwd().to_path_buf();
let target = cwd.join(&p.path);
if !target.is_dir() {
return Err(McpError::invalid_params(
format!("path must be a directory: {}", p.path),
None,
));
}
let global = GlobalFlags::with_cwd(&cwd);
let paths = crate::cmd::ast::collect_source_files(&target, &global)
.map_err(|e| McpError::internal_error(format!("{e}"), None))?;
let file_pairs: Vec<(std::path::PathBuf, String)> = paths
.iter()
.map(|fp| {
let display = crate::cmd::ast::display_path(fp, &cwd);
(fp.clone(), display)
})
.collect();
let opts = crate::ast::map::MapOptions {
max_tokens: p.max_tokens,
focus: &p.focus,
boost: &p.boost,
};
let entries = match crate::ast::map::try_generate_map(&file_pairs, &opts) {
Ok(e) => e,
Err(e) if crate::exit::is_parse_timeout(&e) => {
let msg = crate::exit::agent_error_message(&e);
let body = serde_json::json!({
"ok": false,
"applied": false,
"error_kind": "parse_timeout",
"error": msg,
});
return exit_code_to_result(exit::PARSE_ERROR, &body.to_string(), &msg);
}
Err(e) => return Err(McpError::internal_error(format!("{e}"), None)),
};
if entries.is_empty() {
if let Some(err) = crate::ops::file::empty_scan_masked_by_unreadable(&paths, &cwd) {
let msg = err.msg;
let body = serde_json::json!({
"ok": false,
"applied": false,
"error_kind": "invalid_input",
"error": msg,
});
return exit_code_to_result(exit::FAILURE, &body.to_string(), &msg);
}
return no_results("No symbols found.");
}
let json = serde_json::to_string_pretty(&entries)
.map_err(|e| McpError::internal_error(format!("{e}"), None))?;
Ok(CallToolResult::success(vec![ContentBlock::text(json)]))
}
pub(super) fn handle_ast_diff(
svc: &PatchloomService,
p: AstDiffParams,
) -> Result<CallToolResult, McpError> {
svc.check_path(&p.path)?;
validate_param_size("from", &p.from)?;
if let Some(ref to) = p.to {
validate_param_size("to", to)?;
}
let cwd = svc.cwd().to_path_buf();
let target = cwd.join(&p.path);
let lang_hint = match parse_optional_lang(p.lang.as_deref()) {
Ok(lang) => lang,
Err(r) => return *r,
};
let lang = lang_hint.unwrap_or_else(|| crate::ast::Language::from_path(&target));
let old_source = crate::cmd::ast::get_git_file_content(&cwd, &p.path, &p.from)
.map_err(|e| McpError::internal_error(format!("{e}"), None))?;
let new_source = if let Some(ref to_ref) = p.to {
crate::cmd::ast::get_git_file_content(&cwd, &p.path, to_ref)
.map_err(|e| McpError::internal_error(format!("{e}"), None))?
} else {
crate::files::load_text_strict(&target, &p.path).map_err(|e| {
if crate::exit::is_load_text_strict_fail(&e) || crate::exit::is_io_not_found(&e) {
McpError::invalid_params(e.to_string(), None)
} else {
McpError::internal_error(e.to_string(), None)
}
})?
};
let changes = match crate::ast::diff::try_structural_diff(&old_source, &new_source, lang) {
Ok(c) => c,
Err(crate::ast::ParseFailure::DeadlineExceeded) => {
let msg = format!("parse deadline exceeded for {}", p.path);
let body = serde_json::json!({
"ok": false,
"applied": false,
"error_kind": "parse_timeout",
"error": msg,
});
return exit_code_to_result(exit::PARSE_ERROR, &body.to_string(), &msg);
}
Err(crate::ast::ParseFailure::NoGrammar) => Vec::new(),
};
if changes.is_empty() {
return no_results("No structural changes.");
}
let obj = serde_json::json!({
"file": p.path,
"from": p.from,
"to": p.to.as_deref().unwrap_or("working tree"),
"changes": changes,
});
let json = serde_json::to_string_pretty(&obj)
.map_err(|e| McpError::internal_error(format!("{e}"), None))?;
Ok(CallToolResult::success(vec![ContentBlock::text(json)]))
}
pub(super) fn handle_ast_impact(
svc: &PatchloomService,
p: AstImpactParams,
) -> Result<CallToolResult, McpError> {
svc.check_path(&p.path)?;
validate_param_size("symbol", &p.symbol)?;
let cwd = svc.cwd().to_path_buf();
let target = cwd.join(&p.path);
let global = GlobalFlags::with_cwd(&cwd);
let paths = crate::cmd::ast::resolve_target_paths(&target, &p.path, &global)
.map_err(|e| McpError::invalid_params(format!("{e}"), None))?;
if crate::cmd::ast::is_sole_explicit_file(&paths, &p.path) {
let sole = &paths[0];
if let Err(e) = crate::files::load_text_strict(sole, &p.path)
&& (crate::exit::is_load_text_strict_fail(&e) || crate::exit::is_io_not_found(&e))
{
return Err(McpError::invalid_params(
crate::exit::agent_error_message(&e),
None,
));
}
}
let file_pairs: Vec<(std::path::PathBuf, String)> = paths
.iter()
.map(|fp| {
let display = crate::cmd::ast::display_path(fp, &cwd);
(fp.clone(), display)
})
.collect();
let nodes = match crate::ast::impact::try_compute_impact(&p.symbol, &file_pairs, p.depth) {
Ok(n) => n,
Err(crate::ast::ParseFailure::DeadlineExceeded) => {
let msg = format!("parse deadline exceeded for {}", p.path);
let body = serde_json::json!({
"ok": false,
"applied": false,
"error_kind": "parse_timeout",
"error": msg,
});
return exit_code_to_result(exit::PARSE_ERROR, &body.to_string(), &msg);
}
Err(crate::ast::ParseFailure::NoGrammar) => Vec::new(),
};
if nodes.is_empty() {
if let Some(err) = crate::ops::file::empty_scan_masked_by_unreadable(&paths, &cwd) {
return Err(McpError::invalid_params(err.msg, None));
}
return no_results(&format!("No references found for '{}'.", p.symbol));
}
let obj = serde_json::json!({
"symbol": p.symbol,
"depth": p.depth,
"impact": nodes,
"total_count": nodes.len(),
});
let json = serde_json::to_string_pretty(&obj)
.map_err(|e| McpError::internal_error(format!("{e}"), None))?;
Ok(CallToolResult::success(vec![ContentBlock::text(json)]))
}
pub(super) fn handle_ast_replace(
svc: &PatchloomService,
p: AstReplaceParams,
) -> Result<CallToolResult, McpError> {
svc.check_path(&p.path)?;
validate_param_size("old", &p.old)?;
validate_content_size("new", &p.new_text)?;
validate_param_size("symbol", &p.symbol)?;
let op = crate::plan::Operation::AstReplace {
path: p.path,
symbol: p.symbol,
old: p.old,
new_text: p.new_text,
regex: p.regex,
lang: p.lang,
};
svc.run_one_op(op, None)
}
pub(super) fn handle_ast_rewrite_signature(
svc: &PatchloomService,
p: AstRewriteSignatureParams,
) -> Result<CallToolResult, McpError> {
svc.check_path(&p.path)?;
validate_param_size("old", &p.old)?;
if let Some(ref sig) = p.new_signature {
validate_content_size("new_signature", sig)?;
}
let op = crate::plan::Operation::AstRewriteSignature {
path: p.path,
old: p.old,
new_signature: p.new_signature,
visibility: p.visibility,
parameters: p.parameters,
return_type: p.return_type,
lang: p.lang,
};
svc.run_one_op(op, None)
}
pub(super) fn handle_ast_insert(
svc: &PatchloomService,
p: AstInsertParams,
) -> Result<CallToolResult, McpError> {
svc.check_path(&p.path)?;
validate_content_size("content", &p.content)?;
let op = crate::plan::Operation::AstInsert {
path: p.path,
content: p.content,
inside: p.inside,
after: p.after,
before: p.before,
position: p.position,
lang: p.lang,
};
svc.run_one_op(op, None)
}
pub(super) fn handle_ast_wrap(
svc: &PatchloomService,
p: AstWrapParams,
) -> Result<CallToolResult, McpError> {
svc.check_path(&p.path)?;
validate_param_size("wrapper", &p.wrapper)?;
let op = crate::plan::Operation::AstWrap {
path: p.path,
symbols: p.symbols,
lines: p.lines,
wrapper: p.wrapper,
preamble: p.preamble,
lang: p.lang,
};
svc.run_one_op(op, None)
}
pub(super) fn handle_ast_imports(
svc: &PatchloomService,
p: AstImportsParams,
) -> Result<CallToolResult, McpError> {
svc.check_path(&p.path)?;
if p.add.is_none() && p.remove.is_none() && !p.dedupe {
let cwd = svc.cwd().to_path_buf();
let target = cwd.join(&p.path);
let lang_hint = match parse_optional_lang(p.lang.as_deref()) {
Ok(lang) => lang,
Err(r) => return *r,
};
let lang = lang_hint.unwrap_or_else(|| crate::ast::Language::from_path(&target));
let source = crate::files::load_text_strict(&target, &p.path).map_err(|e| {
if crate::exit::is_load_text_strict_fail(&e) || crate::exit::is_io_not_found(&e) {
McpError::invalid_params(e.to_string(), None)
} else {
McpError::internal_error(e.to_string(), None)
}
})?;
let imports = crate::ast::imports::list_imports(&source, lang);
let obj = serde_json::json!({
"file": p.path,
"imports": imports.iter().map(|i| serde_json::json!({
"text": i.text,
"line": i.line,
})).collect::<Vec<_>>(),
"count": imports.len(),
});
let json = serde_json::to_string_pretty(&obj)
.map_err(|e| McpError::internal_error(format!("{e}"), None))?;
return Ok(CallToolResult::success(vec![ContentBlock::text(json)]));
}
let op = crate::plan::Operation::AstImports {
path: p.path,
add: p.add,
remove: p.remove,
dedupe: p.dedupe,
lang: p.lang,
};
svc.run_one_op(op, None)
}
pub(super) fn handle_ast_reorder(
svc: &PatchloomService,
p: AstReorderParams,
) -> Result<CallToolResult, McpError> {
svc.check_path(&p.path)?;
let op = crate::plan::Operation::AstReorder {
path: p.path,
inside: p.inside,
order: p.order,
lang: p.lang,
};
svc.run_one_op(op, None)
}
pub(super) fn handle_ast_group(
svc: &PatchloomService,
p: AstGroupParams,
) -> Result<CallToolResult, McpError> {
svc.check_path(&p.path)?;
let op = crate::plan::Operation::AstGroup {
path: p.path,
module: p.module,
symbols: p.symbols,
preamble: p.preamble,
position: p.position,
lang: p.lang,
};
svc.run_one_op(op, None)
}
pub(super) fn handle_ast_move(
svc: &PatchloomService,
p: AstMoveParams,
) -> Result<CallToolResult, McpError> {
svc.check_path(&p.path)?;
svc.check_path(&p.target)?;
let op = crate::plan::Operation::AstMove {
path: p.path,
target: p.target,
symbols: p.symbols,
position: p.position,
target_prepend: p.target_prepend,
lang: p.lang,
update_imports: p.update_imports,
old_module_path: p.old_module_path,
new_module_path: p.new_module_path,
};
svc.run_one_op(op, None)
}
pub(super) fn handle_ast_extract_to_file(
svc: &PatchloomService,
p: AstExtractToFileParams,
) -> Result<CallToolResult, McpError> {
svc.check_path(&p.source)?;
svc.check_path(&p.target)?;
let op = crate::plan::Operation::AstExtractToFile {
source: p.source,
symbol: p.symbol,
target: p.target,
replacement: p.replacement,
unwrap: p.unwrap,
prepend: p.prepend,
force: p.force,
lang: p.lang,
update_imports: p.update_imports,
old_module_path: p.old_module_path,
new_module_path: p.new_module_path,
};
svc.run_one_op(op, None)
}
pub(super) fn handle_ast_split(
svc: &PatchloomService,
p: AstSplitParams,
) -> Result<CallToolResult, McpError> {
svc.check_path(&p.source)?;
for t in &p.targets {
svc.check_path(&t.path)?;
}
let targets: Vec<crate::plan::SplitTargetSpec> = p
.targets
.into_iter()
.map(|t| crate::plan::SplitTargetSpec {
path: t.path,
symbols: t.symbols,
prepend: t.prepend,
})
.collect();
let op = crate::plan::Operation::AstSplit {
source: p.source,
targets,
keep_in_source: p.keep_in_source,
source_suffix: p.source_suffix,
source_prefix: p.source_prefix,
require_exhaustive: p.require_exhaustive,
lang: p.lang,
};
svc.run_one_op(op, None)
}
#[cfg(test)]
mod tests {
use super::*;
use rmcp::model::ContentBlock;
use tempfile::TempDir;
fn make_service(dir: &TempDir) -> PatchloomService {
PatchloomService::new(dir.path().to_path_buf(), None).unwrap()
}
fn extract_text(result: &CallToolResult) -> String {
match &result.content[0] {
ContentBlock::Text(t) => t.text.clone(),
other => panic!("expected text content, got {other:?}"),
}
}
const RUST_SAMPLE: &str = r#"
fn greet(name: &str) -> String {
format!("Hello, {name}!")
}
struct Point {
x: f64,
y: f64,
}
impl Point {
fn origin() -> Self {
Point { x: 0.0, y: 0.0 }
}
}
"#;
#[test]
fn ast_list_single_file() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("sample.rs"), RUST_SAMPLE).unwrap();
let svc = make_service(&dir);
let params = AstListParams {
path: "sample.rs".into(),
kind: None,
lang: Some("rs".into()),
};
let result = handle_ast_list(&svc, params).unwrap();
let text = extract_text(&result);
assert!(text.contains("greet"));
assert!(text.contains("Point"));
}
#[test]
fn ast_list_kind_filter() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("sample.rs"), RUST_SAMPLE).unwrap();
let svc = make_service(&dir);
let params = AstListParams {
path: "sample.rs".into(),
kind: Some("struct".into()),
lang: Some("rs".into()),
};
let result = handle_ast_list(&svc, params).unwrap();
let text = extract_text(&result);
assert!(text.contains("Point"));
assert!(!text.contains("\"greet\""));
}
#[test]
fn ast_list_unknown_lang() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("mod.py"), "def x():\n pass\n").unwrap();
let svc = make_service(&dir);
let params = AstListParams {
path: "mod.py".into(),
kind: None,
lang: Some("python3".into()),
};
let result = handle_ast_list(&svc, params).expect("unknown lang is a tool result");
assert!(
result.is_error.unwrap_or(false),
"unknown lang must set isError so hosts do not retry as invalid_params"
);
let text = extract_text(&result);
assert!(
text.contains("invalid_input"),
"unknown lang must surface invalid_input, got: {text}"
);
assert!(text.contains("python3"), "must name the token: {text}");
assert!(
text.contains("\"error_kind\""),
"must not be protocol-only invalid_params without error_kind: {text}"
);
}
#[test]
fn ast_list_path_not_found() {
let dir = TempDir::new().unwrap();
let svc = make_service(&dir);
let params = AstListParams {
path: "nonexistent.rs".into(),
kind: None,
lang: None,
};
let result = handle_ast_list(&svc, params);
result.expect_err("expected error");
}
#[test]
fn ast_read_symbol() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("sample.rs"), RUST_SAMPLE).unwrap();
let svc = make_service(&dir);
let params = AstReadParams {
path: "sample.rs".into(),
symbol: "greet".into(),
context: 0,
lang: Some("rs".into()),
};
let result = handle_ast_read(&svc, params).unwrap();
let text = extract_text(&result);
assert!(text.contains("greet"));
assert!(text.contains("Hello"));
}
#[test]
fn ast_read_symbol_not_found() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("sample.rs"), RUST_SAMPLE).unwrap();
let svc = make_service(&dir);
let params = AstReadParams {
path: "sample.rs".into(),
symbol: "nonexistent_fn".into(),
context: 0,
lang: Some("rs".into()),
};
let result = handle_ast_read(&svc, params).expect("miss is a tool result");
assert!(
result.is_error.unwrap_or(false),
"missing symbol must set isError so hosts do not retry as invalid_params"
);
let text = extract_text(&result);
assert!(
text.contains("no_matches"),
"read miss must surface no_matches, got: {text}"
);
assert!(
text.contains("symbol 'nonexistent_fn' not found in sample.rs"),
"must keep the English miss, got: {text}"
);
}
#[test]
fn ast_validate_valid_file() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("valid.rs"), RUST_SAMPLE).unwrap();
let svc = make_service(&dir);
let params = AstValidateParams {
path: "valid.rs".into(),
lang: Some("rs".into()),
};
let result = handle_ast_validate(&svc, params).unwrap();
let text = extract_text(&result);
assert!(text.contains("\"valid\": true"));
}
#[test]
fn ast_validate_syntax_error() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("bad.rs"), "fn broken( {").unwrap();
let svc = make_service(&dir);
let params = AstValidateParams {
path: "bad.rs".into(),
lang: Some("rs".into()),
};
let result = handle_ast_validate(&svc, params).unwrap();
assert!(
result.is_error.is_some_and(|v| v),
"invalid syntax must set isError so agents do not treat as clean"
);
let text = extract_text(&result);
assert!(text.contains("\"valid\": false"));
}
#[test]
fn ast_search_finds_pattern() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("sample.rs"), RUST_SAMPLE).unwrap();
let svc = make_service(&dir);
let params = AstSearchParams {
path: "sample.rs".into(),
query: "(function_item name: (identifier) @name)".into(),
pattern: false,
lang: Some("rs".into()),
max_results: None,
};
let result = handle_ast_search(&svc, params).unwrap();
let text = extract_text(&result);
assert!(text.contains("greet"));
}
#[test]
fn ast_search_empty_pattern_is_invalid_input() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("sample.rs"), RUST_SAMPLE).unwrap();
let svc = make_service(&dir);
for query in ["", " "] {
let params = AstSearchParams {
path: "sample.rs".into(),
query: query.into(),
pattern: true,
lang: Some("rs".into()),
max_results: None,
};
let result = handle_ast_search(&svc, params).expect("empty pattern is a tool result");
assert!(
result.is_error.unwrap_or(false),
"empty pattern must set isError so hosts do not treat whole-file hit as success"
);
let text = extract_text(&result);
let v: serde_json::Value = serde_json::from_str(&text)
.unwrap_or_else(|e| panic!("tool text must be JSON: {e}\n{text}"));
assert_eq!(
v["error_kind"].as_str(),
Some("invalid_input"),
"empty pattern must peel invalid_input, got: {text}"
);
assert!(
v["error"]
.as_str()
.is_some_and(|s| s.contains("must not be empty")),
"must name empty pattern, got: {text}"
);
}
}
#[test]
fn ast_search_sole_file_timeout_is_parse_timeout() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join("deep.rs"),
crate::ast::nested_rust_source_for_timeout(80_000),
)
.unwrap();
let svc = make_service(&dir);
let _guard = crate::ast::ParseTimeoutGuard::set(std::time::Duration::from_millis(1));
let params = AstSearchParams {
path: "deep.rs".into(),
query: "(function_item) @fn".into(),
pattern: false,
lang: Some("rs".into()),
max_results: None,
};
let result = handle_ast_search(&svc, params).expect("timeout is a tool result");
assert!(
result.is_error.unwrap_or(false),
"sole-path search timeout must not become no matches"
);
let text = extract_text(&result);
assert!(
text.contains("parse_timeout"),
"timeout must surface parse_timeout, got: {text}"
);
assert!(
text.contains("\"applied\":false") || text.contains("\"applied\": false"),
"parse_timeout JSON must set applied:false: {text}"
);
assert!(
!text.contains("No matches found"),
"search_file timeout must not be .ok()?-swallowed: {text}"
);
}
#[test]
fn ast_validate_sole_file_timeout_is_parse_timeout() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join("deep.rs"),
crate::ast::nested_rust_source_for_timeout(80_000),
)
.unwrap();
let svc = make_service(&dir);
let _guard = crate::ast::ParseTimeoutGuard::set(std::time::Duration::from_millis(1));
let params = AstValidateParams {
path: "deep.rs".into(),
lang: Some("rs".into()),
};
let result = handle_ast_validate(&svc, params).expect("timeout is a tool result");
assert!(
result.is_error.unwrap_or(false),
"sole-path validate timeout must not become a valid:false row"
);
let text = extract_text(&result);
assert!(
text.contains("parse_timeout"),
"timeout must surface parse_timeout, not a walk-soft valid:false row only: {text}"
);
assert!(
text.contains("\"applied\":false") || text.contains("\"applied\": false"),
"parse_timeout JSON must set applied:false: {text}"
);
let walk_soft = (text.contains("\"valid\": false") || text.contains("\"valid\":false"))
&& !text.contains("parse_timeout");
assert!(
!walk_soft,
"must not be a walk-soft valid:false row only: {text}"
);
}
#[test]
fn ast_map_unreadable_sibling_is_invalid_input() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("empty.rs"), "// no symbols\n").unwrap();
let locked = dir.path().join("locked.rs");
std::fs::write(&locked, "fn bar() {}\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
if std::fs::read_to_string(&locked).is_ok() {
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o644)).unwrap();
return;
}
let svc = make_service(&dir);
let params = AstMapParams {
path: ".".into(),
max_tokens: 1024,
focus: Vec::new(),
boost: Vec::new(),
};
let result = handle_ast_map(&svc, params).expect("unreadable sibling is a tool result");
assert!(
result.is_error.unwrap_or(false),
"empty map must not mask an unreadable sibling as no symbols"
);
let text = extract_text(&result);
assert!(
text.contains("invalid_input"),
"unreadable sibling must surface invalid_input, got: {text}"
);
assert!(
text.contains("\"ok\":false") || text.contains("\"ok\": false"),
"invalid_input JSON must set ok:false: {text}"
);
assert!(
text.contains("\"applied\":false") || text.contains("\"applied\": false"),
"invalid_input JSON must set applied:false: {text}"
);
assert!(
!text.contains("No symbols found"),
"must not claim no symbols when a scanned sibling is unreadable: {text}"
);
assert!(
!text.contains("invalid_params"),
"must be tool envelope, not JSON-RPC invalid_params: {text}"
);
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o644)).unwrap();
}
#[cfg(not(unix))]
{
let _ = locked;
}
}
#[test]
fn ast_deps_reverse_unreadable_sibling_is_invalid_input() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("target.rs"), "fn foo() {}\n").unwrap();
let locked = dir.path().join("locked.rs");
std::fs::write(&locked, "fn bar() {}\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
if std::fs::read_to_string(&locked).is_ok() {
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o644)).unwrap();
return;
}
let svc = make_service(&dir);
let params = AstDepsParams {
path: "target.rs".into(),
reverse: true,
lang: Some("rs".into()),
};
let result =
handle_ast_deps(&svc, params).expect("unreadable sibling is a tool result");
assert!(
result.is_error.unwrap_or(false),
"reverse scan must not mask an unreadable sibling as no imports"
);
let text = extract_text(&result);
assert!(
text.contains("invalid_input"),
"unreadable sibling must surface invalid_input, got: {text}"
);
assert!(
text.contains("\"applied\":false") || text.contains("\"applied\": false"),
"invalid_input JSON must set applied:false: {text}"
);
assert!(
!text.contains("No imports found"),
"must not claim no imports when a scanned sibling is unreadable: {text}"
);
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o644)).unwrap();
}
#[cfg(not(unix))]
{
let _ = locked;
}
}
#[test]
fn ast_deps_reverse_finds_importer_outside_target_parent() {
let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join("src")).unwrap();
std::fs::create_dir_all(dir.path().join("tests")).unwrap();
std::fs::write(dir.path().join("src/foo.rs"), "pub fn foo() {}\n").unwrap();
std::fs::write(dir.path().join("src/lib.rs"), "use crate::foo;\n").unwrap();
std::fs::write(dir.path().join("tests/import.rs"), "use crate::foo;\n").unwrap();
let svc = make_service(&dir);
let params = AstDepsParams {
path: "src/foo.rs".into(),
reverse: true,
lang: Some("rs".into()),
};
let result = handle_ast_deps(&svc, params).expect("reverse deps is a tool result");
let text = extract_text(&result);
let rows: Vec<serde_json::Value> = serde_json::from_str(&text)
.unwrap_or_else(|e| panic!("expected JSON rows, got {e}: {text}"));
let files: Vec<String> = rows
.iter()
.filter_map(|r| r.get("file").and_then(|v| v.as_str()))
.map(|s| s.replace('\\', "/"))
.collect();
assert!(
files.iter().any(|f| f == "tests/import.rs"),
"reverse deps must scan cwd and include importers outside dest parent, got: {text}"
);
assert!(
files.iter().any(|f| f == "src/lib.rs"),
"reverse deps must still report the in-parent importer, got: {text}"
);
}
#[test]
fn ast_deps_reverse_matches_dotted_import_stem() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("foo.py"), "def foo():\n pass\n").unwrap();
std::fs::write(dir.path().join("importer.py"), "from pkg.foo import x\n").unwrap();
let svc = make_service(&dir);
let params = AstDepsParams {
path: "foo.py".into(),
reverse: true,
lang: Some("py".into()),
};
let result = handle_ast_deps(&svc, params).expect("reverse deps is a tool result");
let text = extract_text(&result);
assert!(
text.contains("importer.py"),
"dotted import pkg.foo must match stem foo, got: {text}"
);
assert!(
text.contains("pkg.foo"),
"result must include the dotted import path, got: {text}"
);
}
#[test]
fn ast_rename_sole_file_timeout_is_parse_timeout() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("deep.rs");
let original = crate::ast::nested_rust_source_for_timeout(80_000);
std::fs::write(&path, &original).unwrap();
let svc = make_service(&dir);
let _guard = crate::ast::ParseTimeoutGuard::set(std::time::Duration::from_millis(1));
let params = AstRenameParams {
path: "deep.rs".into(),
old: "x".into(),
new: "y".into(),
lang: Some("rs".into()),
};
let result = handle_ast_rename(&svc, params).expect("timeout is a tool result");
assert!(
result.is_error.unwrap_or(false),
"sole-path rename timeout must not apply a word-boundary write"
);
let text = extract_text(&result);
assert!(
text.contains("parse_timeout"),
"timeout must surface parse_timeout, got: {text}"
);
assert!(
text.contains("\"applied\":false") || text.contains("\"applied\": false"),
"parse_timeout JSON must set applied:false: {text}"
);
let after = std::fs::read_to_string(&path).unwrap();
assert_eq!(after, original, "timeout must not word-boundary-write");
}
#[test]
fn ast_deps_one_file_dir_binary_is_no_imports() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("only.rs"), b"use foo::Bar;\0").unwrap();
let svc = make_service(&dir);
let result = handle_ast_deps(
&svc,
AstDepsParams {
path: ".".into(),
reverse: false,
lang: None,
},
)
.expect("one-file dir must not hard-fail");
let text = extract_text(&result);
assert!(
!text.to_lowercase().contains("binary"),
"dir walk must not name the directory as binary: {text}"
);
}
#[test]
fn ast_refs_one_file_dir_binary_is_no_refs() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("only.rs"), b"fn main() {}\0").unwrap();
let svc = make_service(&dir);
let result = handle_ast_refs(
&svc,
AstRefsParams {
path: ".".into(),
symbol: "main".into(),
include_def: true,
lang: None,
},
)
.expect("one-file dir must not hard-fail");
let text = extract_text(&result);
assert!(
!text.to_lowercase().contains("binary"),
"dir walk must not name the directory as binary: {text}"
);
}
#[test]
fn ast_search_one_file_dir_binary_is_no_matches() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("only.rs"), b"fn main() {}\0").unwrap();
let svc = make_service(&dir);
let result = handle_ast_search(
&svc,
AstSearchParams {
path: ".".into(),
query: "(function_item) @fn".into(),
pattern: false,
lang: None,
max_results: None,
},
)
.expect("one-file dir must not hard-fail");
let text = extract_text(&result);
assert!(
!text.to_lowercase().contains("binary"),
"dir walk must not name the directory as binary: {text}"
);
}
#[test]
fn ast_impact_one_file_dir_binary_is_no_refs() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("only.rs"), b"fn main() {}\0").unwrap();
let svc = make_service(&dir);
let result = handle_ast_impact(
&svc,
AstImpactParams {
path: ".".into(),
symbol: "main".into(),
depth: 3,
},
)
.expect("one-file dir must not hard-fail");
let text = extract_text(&result);
assert!(
!text.to_lowercase().contains("binary"),
"dir walk must not name the directory as binary: {text}"
);
}
#[test]
fn ast_rename_one_file_dir_binary_is_no_matches() {
let dir = TempDir::new().unwrap();
let dest = dir.path().join("only.rs");
let original = b"fn keep() {}\0";
std::fs::write(&dest, original).unwrap();
let svc = make_service(&dir);
let result = handle_ast_rename(
&svc,
AstRenameParams {
path: ".".into(),
old: "absent".into(),
new: "other".into(),
lang: None,
},
)
.expect("one-file dir must not hard-fail");
let text = extract_text(&result);
assert!(
!text.contains("invalid_params"),
"dir walk must not be invalid_params: {text}"
);
assert!(
!text.to_lowercase().contains("binary"),
"dir walk must not name the directory as binary: {text}"
);
let after = std::fs::read(&dest).unwrap();
assert_eq!(after, original.as_slice());
}
#[test]
fn ast_rename_unknown_lang() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("mod.py");
let original = "def greet():\n pass\n";
std::fs::write(&path, original).unwrap();
let svc = make_service(&dir);
let params = AstRenameParams {
path: "mod.py".into(),
old: "greet".into(),
new: "salute".into(),
lang: Some("python3".into()),
};
let result = handle_ast_rename(&svc, params).expect("unknown lang is a tool result");
assert!(
result.is_error.unwrap_or(false),
"unknown lang must set isError so hosts do not retry as invalid_params"
);
let text = extract_text(&result);
assert!(
text.contains("invalid_input"),
"unknown lang must surface invalid_input, got: {text}"
);
assert!(text.contains("python3"), "must name the token: {text}");
assert!(
text.contains("\"error_kind\""),
"must not be protocol-only invalid_params without error_kind: {text}"
);
let after = std::fs::read_to_string(&path).unwrap();
assert_eq!(after, original, "unknown lang must not mutate dest");
}
#[test]
fn ast_rename_empty_new_is_invalid_input() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("rename.rs"), RUST_SAMPLE).unwrap();
let svc = make_service(&dir);
for new in [String::new(), " ".into()] {
let params = AstRenameParams {
path: "rename.rs".into(),
old: "greet".into(),
new,
lang: Some("rs".into()),
};
let result = handle_ast_rename(&svc, params).expect("empty new is a tool result");
assert!(
result.is_error.unwrap_or(false),
"empty new must set isError"
);
let text = extract_text(&result);
let v: serde_json::Value = serde_json::from_str(&text)
.unwrap_or_else(|e| panic!("tool text must be JSON: {e}\n{text}"));
assert_eq!(v["error_kind"].as_str(), Some("invalid_input"));
let after = std::fs::read_to_string(dir.path().join("rename.rs")).unwrap();
assert_eq!(after, RUST_SAMPLE, "empty new must not mutate dest");
}
}
#[test]
fn ast_insert_empty_content_is_invalid_input() {
let dir = TempDir::new().unwrap();
let original = "fn foo() { let x = 1; }\n";
std::fs::write(dir.path().join("t.rs"), original).unwrap();
let svc = make_service(&dir);
let params = AstInsertParams {
path: "t.rs".into(),
content: String::new(),
inside: None,
after: Some("foo".into()),
before: None,
position: None,
lang: Some("rs".into()),
};
let result = handle_ast_insert(&svc, params).expect("empty content is a tool result");
assert!(
result.is_error.unwrap_or(false),
"empty insert content must set isError"
);
let text = extract_text(&result);
let v: serde_json::Value = serde_json::from_str(&text)
.unwrap_or_else(|e| panic!("tool text must be JSON: {e}\n{text}"));
assert_eq!(v["error_kind"].as_str(), Some("invalid_input"));
let after = std::fs::read_to_string(dir.path().join("t.rs")).unwrap();
assert_eq!(after, original, "empty insert must not mutate dest");
}
#[test]
fn ast_wrap_empty_wrapper_is_invalid_input() {
let dir = TempDir::new().unwrap();
let original = "fn foo() { let x = 1; }\n";
std::fs::write(dir.path().join("t.rs"), original).unwrap();
let svc = make_service(&dir);
let params = AstWrapParams {
path: "t.rs".into(),
symbols: Some(vec!["foo".into()]),
lines: None,
wrapper: String::new(),
preamble: None,
lang: Some("rs".into()),
};
let result = handle_ast_wrap(&svc, params).expect("empty wrapper is a tool result");
assert!(
result.is_error.unwrap_or(false),
"empty wrap wrapper must set isError"
);
let text = extract_text(&result);
let v: serde_json::Value = serde_json::from_str(&text)
.unwrap_or_else(|e| panic!("tool text must be JSON: {e}\n{text}"));
assert_eq!(v["error_kind"].as_str(), Some("invalid_input"));
let after = std::fs::read_to_string(dir.path().join("t.rs")).unwrap();
assert_eq!(after, original, "empty wrap must not mutate dest");
}
#[test]
fn ast_rewrite_empty_new_signature_is_invalid_input() {
let dir = TempDir::new().unwrap();
let original = "fn foo() { let x = 1; }\nfn bar() {}\n";
std::fs::write(dir.path().join("t.rs"), original).unwrap();
let svc = make_service(&dir);
let params = AstRewriteSignatureParams {
path: "t.rs".into(),
old: "foo".into(),
new_signature: Some(String::new()),
visibility: None,
parameters: None,
return_type: None,
lang: Some("rs".into()),
};
let result = handle_ast_rewrite_signature(&svc, params)
.expect("empty new_signature is a tool result");
assert!(
result.is_error.unwrap_or(false),
"empty new_signature must set isError"
);
let text = extract_text(&result);
let v: serde_json::Value = serde_json::from_str(&text)
.unwrap_or_else(|e| panic!("tool text must be JSON: {e}\n{text}"));
assert_eq!(v["error_kind"].as_str(), Some("invalid_input"));
let after = std::fs::read_to_string(dir.path().join("t.rs")).unwrap();
assert_eq!(after, original, "empty new_signature must not mutate dest");
}
#[test]
fn ast_rewrite_empty_parameters_is_invalid_input() {
let dir = TempDir::new().unwrap();
let original = "fn foo(x: i32) { let x = 1; }\n";
std::fs::write(dir.path().join("t.rs"), original).unwrap();
let svc = make_service(&dir);
let params = AstRewriteSignatureParams {
path: "t.rs".into(),
old: "foo".into(),
new_signature: None,
visibility: None,
parameters: Some(String::new()),
return_type: None,
lang: Some("rs".into()),
};
let result =
handle_ast_rewrite_signature(&svc, params).expect("empty parameters is a tool result");
assert!(
result.is_error.unwrap_or(false),
"empty parameters must set isError"
);
let text = extract_text(&result);
let v: serde_json::Value = serde_json::from_str(&text)
.unwrap_or_else(|e| panic!("tool text must be JSON: {e}\n{text}"));
assert_eq!(v["error_kind"].as_str(), Some("invalid_input"));
let after = std::fs::read_to_string(dir.path().join("t.rs")).unwrap();
assert_eq!(after, original, "empty parameters must not mutate dest");
}
#[test]
fn ast_split_empty_symbols_is_invalid_input() {
let dir = TempDir::new().unwrap();
let original = "fn foo() { let x = 1; }\n";
std::fs::write(dir.path().join("t.rs"), original).unwrap();
let svc = make_service(&dir);
let params = AstSplitParams {
source: "t.rs".into(),
targets: vec![AstSplitTargetParam {
path: "a.rs".into(),
symbols: vec![String::new()],
prepend: None,
}],
keep_in_source: vec![],
source_suffix: None,
source_prefix: None,
require_exhaustive: Some(false),
lang: Some("rs".into()),
};
let result = handle_ast_split(&svc, params).expect("empty split symbol is a tool result");
assert!(
result.is_error.unwrap_or(false),
"empty split symbol must set isError"
);
let text = extract_text(&result);
let v: serde_json::Value = serde_json::from_str(&text)
.unwrap_or_else(|e| panic!("tool text must be JSON: {e}\n{text}"));
assert_eq!(v["error_kind"].as_str(), Some("invalid_input"));
let after = std::fs::read_to_string(dir.path().join("t.rs")).unwrap();
assert_eq!(after, original, "empty split must not mutate source");
assert!(
!dir.path().join("a.rs").exists(),
"empty split must not create dest"
);
}
#[test]
fn ast_move_empty_symbols_is_invalid_input() {
let dir = TempDir::new().unwrap();
let original = "fn foo() { let x = 1; }\n";
std::fs::write(dir.path().join("t.rs"), original).unwrap();
let svc = make_service(&dir);
let params = AstMoveParams {
path: "t.rs".into(),
target: "dest.rs".into(),
symbols: vec![],
position: None,
target_prepend: None,
lang: Some("rs".into()),
update_imports: false,
old_module_path: None,
new_module_path: None,
};
let result = handle_ast_move(&svc, params).expect("empty move symbols is a tool result");
assert!(
result.is_error.unwrap_or(false),
"empty move symbols must set isError"
);
let text = extract_text(&result);
let v: serde_json::Value = serde_json::from_str(&text)
.unwrap_or_else(|e| panic!("tool text must be JSON: {e}\n{text}"));
assert_eq!(v["error_kind"].as_str(), Some("invalid_input"));
assert!(
v["error"]
.as_str()
.is_some_and(|s| s.contains("must not be empty")),
"must name empty symbols, got: {text}"
);
let after = std::fs::read_to_string(dir.path().join("t.rs")).unwrap();
assert_eq!(after, original, "empty move symbols must not mutate source");
assert!(
!dir.path().join("dest.rs").exists(),
"empty move symbols must not create dest"
);
}
#[test]
fn ast_group_empty_module_is_invalid_input() {
let dir = TempDir::new().unwrap();
let original = "fn foo() { let x = 1; }\n";
std::fs::write(dir.path().join("t.rs"), original).unwrap();
let svc = make_service(&dir);
let params = AstGroupParams {
path: "t.rs".into(),
module: String::new(),
symbols: vec!["foo".into()],
preamble: None,
position: None,
lang: Some("rs".into()),
};
let result = handle_ast_group(&svc, params).expect("empty module is a tool result");
assert!(
result.is_error.unwrap_or(false),
"empty group module must set isError"
);
let text = extract_text(&result);
let v: serde_json::Value = serde_json::from_str(&text)
.unwrap_or_else(|e| panic!("tool text must be JSON: {e}\n{text}"));
assert_eq!(v["error_kind"].as_str(), Some("invalid_input"));
let after = std::fs::read_to_string(dir.path().join("t.rs")).unwrap();
assert_eq!(after, original, "empty group module must not mutate dest");
}
#[test]
fn ast_imports_empty_add_item_is_invalid_input() {
let dir = TempDir::new().unwrap();
let original = "fn foo() { let x = 1; }\n";
std::fs::write(dir.path().join("t.rs"), original).unwrap();
let svc = make_service(&dir);
let params = AstImportsParams {
path: "t.rs".into(),
add: Some(vec![String::new()]),
remove: None,
dedupe: false,
lang: Some("rs".into()),
};
let result = handle_ast_imports(&svc, params).expect("empty add item is a tool result");
assert!(
result.is_error.unwrap_or(false),
"empty import add item must set isError"
);
let text = extract_text(&result);
let v: serde_json::Value = serde_json::from_str(&text)
.unwrap_or_else(|e| panic!("tool text must be JSON: {e}\n{text}"));
assert_eq!(v["error_kind"].as_str(), Some("invalid_input"));
let after = std::fs::read_to_string(dir.path().join("t.rs")).unwrap();
assert_eq!(after, original, "empty import add must not mutate dest");
}
#[test]
fn ast_rename_replaces_symbol() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("rename.rs"), RUST_SAMPLE).unwrap();
let svc = make_service(&dir);
let params = AstRenameParams {
path: "rename.rs".into(),
old: "greet".into(),
new: "salute".into(),
lang: Some("rs".into()),
};
let result = handle_ast_rename(&svc, params).unwrap();
let text = extract_text(&result);
assert!(text.contains("\"ok\": true"));
let content = std::fs::read_to_string(dir.path().join("rename.rs")).unwrap();
assert!(content.contains("salute"));
assert!(!content.contains("greet"));
}
#[test]
fn ast_rename_same_name_no_match() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("sample.rs"), RUST_SAMPLE).unwrap();
let svc = make_service(&dir);
let params = AstRenameParams {
path: "sample.rs".into(),
old: "greet".into(),
new: "greet".into(),
lang: Some("rs".into()),
};
let result = handle_ast_rename(&svc, params).unwrap();
assert!(result.is_error.unwrap_or(false));
}
#[test]
fn ast_rename_missing_symbol_is_error_not_soft_success() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("sample.rs"), RUST_SAMPLE).unwrap();
let svc = make_service(&dir);
let params = AstRenameParams {
path: "sample.rs".into(),
old: "does_not_exist_symbol".into(),
new: "other".into(),
lang: Some("rs".into()),
};
let result = handle_ast_rename(&svc, params).unwrap();
assert!(
result.is_error.unwrap_or(false),
"write miss must set isError so agents do not treat rename as applied"
);
let text = extract_text(&result);
assert!(
text.contains("no_matches") || text.contains("No matches"),
"got: {text}"
);
let content = std::fs::read_to_string(dir.path().join("sample.rs")).unwrap();
assert!(
content.contains("greet"),
"file must stay unchanged on rename miss"
);
}
}