use super::super::{McpToolCall, McpToolResult};
use crate::mcp::fs::{directory, file_ops, text_editing};
use crate::utils::truncation::format_extracted_content_smart;
use anyhow::{anyhow, Result};
use lazy_static::lazy_static;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::path::Path;
use std::sync::Mutex;
use tokio::fs as tokio_fs;
fn resolve_line_index(index: i64, total_lines: usize) -> Result<usize, String> {
if index == 0 {
return Err("Line numbers are 1-indexed, use 1 for first line".to_string());
}
if index > 0 {
let pos_index = index as usize;
if pos_index > total_lines {
return Err(format!(
"Line {index} exceeds file length ({total_lines} lines)"
));
}
Ok(pos_index)
} else {
let from_end = (-index) as usize;
if from_end > total_lines {
return Err(format!(
"Negative index {index} exceeds file length ({total_lines} lines)"
));
}
Ok(total_lines - from_end + 1)
}
}
fn resolve_line_range(start: i64, end: i64, total_lines: usize) -> Result<(usize, usize), String> {
let resolved_start = resolve_line_index(start, total_lines)?;
let resolved_end = resolve_line_index(end, total_lines)?;
if resolved_start > resolved_end {
return Err(format!(
"Start line ({start}) cannot be greater than end line ({end})"
));
}
Ok((resolved_start, resolved_end))
}
lazy_static! {
pub static ref FILE_HISTORY: Mutex<HashMap<String, Vec<String>>> = Mutex::new(HashMap::new());
}
pub fn get_file_history() -> &'static Mutex<HashMap<String, Vec<String>>> {
&FILE_HISTORY
}
pub async fn save_file_history(path: &Path) -> Result<()> {
if path.exists() {
let content = tokio_fs::read_to_string(path).await?;
let path_str = path.to_string_lossy().to_string();
let file_history = get_file_history();
{
let mut history_guard = file_history
.lock()
.map_err(|_| anyhow!("Failed to acquire lock on file history"))?;
let history = history_guard.entry(path_str).or_insert_with(Vec::new);
if history.len() >= 10 {
history.remove(0);
}
history.push(content);
} }
Ok(())
}
pub async fn undo_edit(call: &McpToolCall, path: &Path) -> Result<McpToolResult> {
let path_str = path.to_string_lossy().to_string();
let previous_content = {
let file_history = get_file_history();
let mut history_guard = file_history
.lock()
.map_err(|_| anyhow!("Failed to acquire lock on file history"))?;
if let Some(history) = history_guard.get_mut(&path_str) {
history.pop()
} else {
None
}
};
if let Some(prev_content) = previous_content {
tokio_fs::write(path, &prev_content).await?;
let history_remaining = {
let file_history = get_file_history();
let history_guard = file_history
.lock()
.map_err(|_| anyhow!("Failed to acquire lock on file history"))?;
history_guard.get(&path_str).map_or(0, |h| h.len())
};
Ok(McpToolResult::success_with_metadata(
"text_editor".to_string(),
call.tool_id.clone(),
format!(
"Successfully undid the last edit to {}",
path.to_string_lossy()
),
json!({
"path": path.to_string_lossy(),
"history_remaining": history_remaining,
"command": "undo_edit"
}),
))
} else {
Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
"No edit history available for this file".to_string(),
))
}
}
pub fn detect_language(ext: &str) -> &str {
match ext {
"rs" => "rust",
"py" => "python",
"js" => "javascript",
"ts" => "typescript",
"jsx" => "jsx",
"tsx" => "tsx",
"html" => "html",
"css" => "css",
"json" => "json",
"md" => "markdown",
"go" => "go",
"java" => "java",
"c" | "h" | "cpp" => "cpp",
"toml" => "toml",
"yaml" | "yml" => "yaml",
"php" => "php",
"xml" => "xml",
"sh" => "bash",
_ => "text",
}
}
pub async fn execute_text_editor(call: &McpToolCall) -> Result<McpToolResult> {
let command = match call.parameters.get("command") {
Some(Value::String(cmd)) => cmd.clone(),
Some(_) => {
return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
"Command parameter must be a string".to_string(),
));
}
None => {
return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
"Missing required 'command' parameter".to_string(),
));
}
};
match command.as_str() {
"view" => {
let path = match call.parameters.get("path") {
Some(Value::String(p)) => p.clone(),
_ => return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
"Missing or invalid 'path' parameter for view command".to_string(),
)),
};
let lines = match call.parameters.get("lines") {
Some(Value::Array(arr)) if arr.len() == 2 => {
match (arr[0].as_i64(), arr[1].as_i64()) {
(Some(start), Some(end)) => {
let file_path = Path::new(&path);
if file_path.exists() && file_path.is_file() {
match tokio_fs::read_to_string(file_path).await {
Ok(content) => {
let total_lines = content.lines().count();
match resolve_line_range(start, end, total_lines) {
Ok((resolved_start, resolved_end)) => {
Some((resolved_start, resolved_end as i64))
}
Err(err) => {
return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
format!("Invalid lines parameter: {err}"),
));
}
}
}
Err(_) => {
Some((start as usize, end))
}
}
} else {
Some((start as usize, end))
}
}
_ => {
return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
"lines array elements must be integers".to_string(),
));
}
}
}
Some(Value::Array(_)) => {
return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
"lines must be an array with exactly 2 elements".to_string(),
));
}
Some(_) => {
return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
"lines must be an array".to_string(),
));
}
None => None,
};
let result = file_ops::view_file_spec(call, Path::new(&path), lines).await?;
if !result.result.get("isError").and_then(|v| v.as_bool()).unwrap_or(false) {
if let Err(e) = crate::mcp::fs::text_editing::reset_line_count_tracking(Path::new(&path)).await {
crate::log_debug!("Failed to reset line tracking for {}: {}", path, e);
}
}
Ok(result)
},
"view_many" => {
let paths = match call.parameters.get("paths") {
Some(Value::Array(arr)) => {
let path_strings: Result<Vec<String>, _> = arr.iter()
.map(|p| p.as_str().ok_or_else(|| anyhow!("Invalid path in array")))
.map(|r| r.map(|s| s.to_string()))
.collect();
match path_strings {
Ok(paths) => {
if paths.len() > 50 {
return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
"Too many files requested. Maximum 50 files per request.".to_string(),
));
}
paths
},
Err(e) => return Err(e),
}
},
_ => return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
"Missing or invalid 'paths' parameter for view_many command - must be an array of strings".to_string(),
)),
};
file_ops::view_many_files_spec(call, &paths).await
},
"create" => {
let path = match call.parameters.get("path") {
Some(Value::String(p)) => p.clone(),
_ => return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
"Missing or invalid 'path' parameter for create command".to_string(),
)),
};
let content = match call.parameters.get("content") {
Some(Value::String(txt)) => txt.clone(),
_ => return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
"Missing or invalid 'content' parameter for create command".to_string(),
)),
};
file_ops::create_file_spec(call, Path::new(&path), &content).await
},
"str_replace" => {
let path = match call.parameters.get("path") {
Some(Value::String(p)) => p.clone(),
_ => return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
"Missing or invalid 'path' parameter for str_replace command".to_string(),
)),
};
let old_text = match call.parameters.get("old_text") {
Some(Value::String(s)) => s.clone(),
_ => return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
"Missing or invalid 'old_text' parameter".to_string(),
)),
};
let new_text = match call.parameters.get("new_text") {
Some(Value::String(s)) => s.clone(),
_ => return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
"Missing or invalid 'new_text' parameter".to_string(),
)),
};
text_editing::str_replace_spec(call, Path::new(&path), &old_text, &new_text).await
},
"insert" => {
let path = match call.parameters.get("path") {
Some(Value::String(p)) => p.clone(),
_ => return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
"Missing or invalid 'path' parameter for insert command".to_string(),
)),
};
let insert_after_line = match call.parameters.get("insert_after_line") {
Some(Value::Number(n)) => {
match n.as_u64() {
Some(num) => num as usize,
None => return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
"Invalid 'insert_after_line' parameter - must be a valid number".to_string(),
)),
}
},
_ => return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
"Missing or invalid 'insert_after_line' parameter".to_string(),
)),
};
let content = match call.parameters.get("content") {
Some(Value::String(s)) => s.clone(),
_ => return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
"Missing or invalid 'content' parameter for insert command".to_string(),
)),
};
text_editing::insert_text_spec(call, Path::new(&path), insert_after_line, &content).await
},
"line_replace" => {
let path = match call.parameters.get("path") {
Some(Value::String(p)) => p.clone(),
_ => return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
"Missing or invalid 'path' parameter for line_replace command".to_string(),
)),
};
let lines = match call.parameters.get("lines") {
Some(Value::Array(arr)) => {
if arr.len() != 2 {
return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
"'lines' must be an array of exactly 2 integers for line_replace command".to_string(),
));
}
let start = match arr[0].as_u64() {
Some(num) => num as usize,
None => return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
"Invalid start_line in lines parameter".to_string(),
)),
};
let end = match arr[1].as_u64() {
Some(num) => num as usize,
None => return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
"Invalid end_line in lines parameter".to_string(),
)),
};
(start, end)
},
_ => return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
"Missing or invalid 'lines' parameter for line_replace command".to_string(),
)),
};
let content = match call.parameters.get("content") {
Some(Value::String(s)) => s.clone(),
_ => return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
"Missing or invalid 'content' parameter for line_replace command".to_string(),
)),
};
text_editing::line_replace_spec(call, Path::new(&path), lines, &content).await
},
"undo_edit" => {
let path = match call.parameters.get("path") {
Some(Value::String(p)) => p.clone(),
_ => return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
"Missing or invalid 'path' parameter for undo_edit command".to_string(),
)),
};
undo_edit(call, Path::new(&path)).await
},
_ => Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
format!("Invalid command: {command}. Allowed commands are: view, view_many, create, str_replace, insert, line_replace, undo_edit"),
)),
}
}
pub async fn execute_list_files(call: &McpToolCall) -> Result<McpToolResult> {
directory::execute_list_files(call).await
}
pub async fn execute_extract_lines(call: &McpToolCall) -> Result<McpToolResult> {
let from_path = match call.parameters.get("from_path") {
Some(Value::String(p)) => {
if p.trim().is_empty() {
return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
"Parameter 'from_path' cannot be empty".to_string(),
));
}
p.clone()
}
Some(_) => {
return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
"Parameter 'from_path' must be a string".to_string(),
));
}
None => {
return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
"Missing required parameter 'from_path'".to_string(),
));
}
};
let (from_range_start_raw, from_range_end_raw) = match call.parameters.get("from_range") {
Some(Value::Array(arr)) => {
if arr.len() != 2 {
return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
"Parameter 'from_range' must be an array with exactly 2 elements".to_string(),
));
}
let start = match arr[0].as_i64() {
Some(0) => {
return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
"Line numbers are 1-indexed, use 1 for first line".to_string(),
));
}
Some(n) => n,
None => {
return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
"Start line number must be an integer".to_string(),
));
}
};
let end = match arr[1].as_i64() {
Some(0) => {
return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
"Line numbers are 1-indexed, use 1 for first line".to_string(),
));
}
Some(n) => n,
None => {
return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
"End line number must be an integer".to_string(),
));
}
};
(start, end)
}
Some(_) => {
return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
"Parameter 'from_range' must be an array".to_string(),
));
}
None => {
return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
"Missing required parameter 'from_range'".to_string(),
));
}
};
let append_path = match call.parameters.get("append_path") {
Some(Value::String(p)) => {
if p.trim().is_empty() {
return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
"Parameter 'append_path' cannot be empty".to_string(),
));
}
p.clone()
}
Some(_) => {
return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
"Parameter 'append_path' must be a string".to_string(),
));
}
None => {
return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
"Missing required parameter 'append_path'".to_string(),
));
}
};
let append_line = match call.parameters.get("append_line") {
Some(Value::Number(n)) => match n.as_i64() {
Some(line) => line,
None => {
return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
"Parameter 'append_line' must be an integer".to_string(),
));
}
},
Some(_) => {
return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
"Parameter 'append_line' must be an integer".to_string(),
));
}
None => {
return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
"Missing required parameter 'append_line'".to_string(),
));
}
};
let from_path_obj = Path::new(&from_path);
if !from_path_obj.exists() {
return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
format!("Source file does not exist: {from_path}"),
));
}
let source_content = match tokio_fs::read_to_string(&from_path_obj).await {
Ok(content) => content,
Err(e) => {
return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
format!("Failed to read source file '{from_path}': {e}"),
));
}
};
let source_lines: Vec<&str> = source_content.lines().collect();
let total_lines = source_lines.len();
let from_range = match resolve_line_range(from_range_start_raw, from_range_end_raw, total_lines)
{
Ok(range) => range,
Err(err) => {
return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
format!("Invalid from_range: {err}"),
));
}
};
let extracted_lines: Vec<&str> = source_lines[(from_range.0 - 1)..from_range.1].to_vec();
let extracted_content_display = format_extracted_content_smart(
&extracted_lines,
from_range.0, Some(30), );
let source_ends_with_newline = source_content.ends_with('\n');
let extracting_last_line = from_range.1 == total_lines;
let extracted_content =
if extracted_lines.len() == 1 && extracting_last_line && !source_ends_with_newline {
extracted_lines[0].to_string()
} else if extracting_last_line && source_ends_with_newline {
format!("{}\n", extracted_lines.join("\n"))
} else {
extracted_lines.join("\n")
};
let append_path_obj = Path::new(&append_path);
if let Some(parent) = append_path_obj.parent() {
if let Err(e) = tokio_fs::create_dir_all(parent).await {
return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
format!("Failed to create parent directories for '{append_path}': {e}"),
));
}
}
let target_content = if append_path_obj.exists() {
match tokio_fs::read_to_string(&append_path_obj).await {
Ok(content) => content,
Err(e) => {
return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
format!("Failed to read target file '{append_path}': {e}"),
));
}
}
} else {
String::new()
};
let final_content = if append_line == 0 {
if target_content.is_empty() {
extracted_content.clone()
} else {
if extracted_content.ends_with('\n') {
format!("{extracted_content}{target_content}")
} else {
format!("{extracted_content}\n{target_content}")
}
}
} else if append_line == -1 {
if target_content.is_empty() {
extracted_content.clone()
} else if target_content.ends_with('\n') {
format!("{target_content}{extracted_content}")
} else {
format!("{target_content}\n{extracted_content}")
}
} else {
let target_lines: Vec<&str> = target_content.lines().collect();
let insert_after = append_line as usize;
if insert_after > target_lines.len() {
return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
format!(
"Insert position {insert_after} exceeds target file length ({}) lines) in '{append_path}'",
target_lines.len()
),
));
}
let mut new_lines = Vec::new();
new_lines.extend(target_lines[..insert_after].iter().map(|s| s.to_string()));
new_lines.extend(extracted_lines.iter().map(|s| s.to_string()));
if insert_after < target_lines.len() {
new_lines.extend(target_lines[insert_after..].iter().map(|s| s.to_string()));
}
let target_ends_with_newline = target_content.ends_with('\n');
if target_ends_with_newline {
format!("{}\n", new_lines.join("\n"))
} else {
new_lines.join("\n")
}
};
if let Err(e) = tokio_fs::write(&append_path_obj, &final_content).await {
return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
format!("Failed to write to target file '{append_path}': {e}"),
));
}
let lines_extracted = from_range.1 - from_range.0 + 1;
let position_desc = match append_line {
0 => "beginning of file".to_string(),
-1 => "end of file".to_string(),
n => format!("after line {n}"),
};
Ok(McpToolResult::success(
call.tool_name.clone(),
call.tool_id.clone(),
format!(
"Successfully extracted {lines_extracted} lines (lines {}-{}) from '{from_path}' and appended to '{append_path}' at {position_desc}.\n\nExtracted content:\n{extracted_content_display}",
from_range.0,
from_range.1
),
))
}
pub async fn execute_batch_edit(call: &McpToolCall) -> Result<McpToolResult> {
let (operations_vec, ai_format_warning) = match call.parameters.get("operations") {
Some(Value::Array(ops)) => {
if ops.len() > 50 {
return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
"Too many operations in batch. Maximum 50 operations allowed.".to_string(),
));
}
(ops.clone(), false)
}
Some(Value::String(ops_str)) => {
match serde_json::from_str::<Vec<Value>>(ops_str) {
Ok(parsed_ops) => {
if parsed_ops.len() > 50 {
return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
"Too many operations in batch. Maximum 50 operations allowed."
.to_string(),
));
}
crate::log_debug!("AI passed operations as JSON string instead of array - parsing defensively");
(parsed_ops, true)
}
Err(_) => {
return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
"Invalid 'operations' parameter for batch_edit - must be an array or valid JSON array string".to_string(),
));
}
}
}
_ => {
return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
"Missing or invalid 'operations' parameter for batch_edit - must be an array"
.to_string(),
))
}
};
let mut modified_call = call.clone();
if ai_format_warning {
modified_call
.parameters
.as_object_mut()
.unwrap()
.insert("_ai_format_warning".to_string(), Value::Bool(true));
}
text_editing::batch_edit_spec(&modified_call, &operations_vec).await
}