use super::super::{McpToolCall, McpToolResult};
use super::core::save_file_history;
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::Arc;
use tokio::fs as tokio_fs;
use tokio::sync::Mutex;
lazy_static! {
static ref FILE_LOCKS: Mutex<HashMap<String, Arc<Mutex<()>>>> = Mutex::new(HashMap::new());
}
lazy_static! {
static ref FILE_LINE_COUNT_CHANGES: Mutex<HashMap<String, LineCountChangeInfo>> =
Mutex::new(HashMap::new());
}
#[derive(Debug, Clone)]
struct LineCountChangeInfo {
last_operation: String,
original_line_count: usize,
new_line_count: usize,
net_change: i32, }
impl LineCountChangeInfo {
fn new(operation: &str, original_count: usize, new_count: usize) -> Self {
Self {
last_operation: operation.to_string(),
original_line_count: original_count,
new_line_count: new_count,
net_change: new_count as i32 - original_count as i32,
}
}
}
async fn acquire_file_lock(path: &Path) -> Result<Arc<Mutex<()>>> {
let path_str = path.to_string_lossy().to_string();
let mut locks = FILE_LOCKS.lock().await;
let file_lock = locks
.entry(path_str)
.or_insert_with(|| Arc::new(Mutex::new(())))
.clone();
Ok(file_lock)
}
async fn check_and_mark_line_count_change(
path: &Path,
operation: &str,
original_content: &str,
new_content: &str,
) -> Result<()> {
let original_lines = original_content.lines().count();
let new_lines = new_content.lines().count();
if original_lines != new_lines {
let path_str = path.to_string_lossy().to_string();
let mut changes = FILE_LINE_COUNT_CHANGES.lock().await;
changes.insert(
path_str,
LineCountChangeInfo::new(operation, original_lines, new_lines),
);
}
Ok(())
}
async fn has_line_count_changes(path: &Path) -> Result<bool> {
let path_str = path.to_string_lossy().to_string();
let changes = FILE_LINE_COUNT_CHANGES.lock().await;
Ok(changes.contains_key(&path_str))
}
pub async fn reset_line_count_tracking(path: &Path) -> Result<()> {
let path_str = path.to_string_lossy().to_string();
let mut changes = FILE_LINE_COUNT_CHANGES.lock().await;
changes.remove(&path_str);
Ok(())
}
async fn validate_line_dependent_operation(
path: &Path,
operation: &str,
call: &McpToolCall,
) -> Result<Option<McpToolResult>> {
if has_line_count_changes(path).await? {
let path_str = path.to_string_lossy().to_string();
let changes = FILE_LINE_COUNT_CHANGES.lock().await;
if let Some(info) = changes.get(&path_str) {
let change_description = if info.net_change > 0 {
format!("added {} lines", info.net_change)
} else {
format!("removed {} lines", -info.net_change)
};
let error_msg = format!(
"CRITICAL: File line count has been changed. Line numbers are no longer valid. \
Use 'view' or 'view_range' command first to refresh line numbers, then retry your operation.\n\n\
Previous operation: {} ({} → {} lines, {})\n\
File: {}\n\n\
Safe workflow:\n\
1. text_editor(command=\"view\", path=\"{}\") # or view_range\n\
2. [Your {} operation with fresh line numbers]",
info.last_operation,
info.original_line_count,
info.new_line_count,
change_description,
path_str,
path_str,
operation
);
return Ok(Some(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
error_msg,
)));
}
}
Ok(None)
}
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 {} exceeds file length ({} lines)",
index, total_lines
));
}
Ok(pos_index)
} else {
let from_end = (-index) as usize;
if from_end > total_lines {
return Err(format!(
"Negative index {} exceeds file length ({} lines)",
index, total_lines
));
}
Ok(total_lines - from_end + 1)
}
}
fn resolve_line_range_batch(
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 ({}) cannot be greater than end line ({})",
start, end
));
}
Ok((resolved_start, resolved_end))
}
#[derive(Debug, Clone)]
struct BatchOperation {
operation_type: OperationType,
line_range: LineRange,
content: String,
operation_index: usize,
}
#[derive(Debug, Clone)]
struct UnresolvedBatchOperation {
operation_type: OperationType,
line_range: UnresolvedLineRange,
content: String,
operation_index: usize,
}
#[derive(Debug, Clone, PartialEq)]
enum OperationType {
Insert,
Replace,
}
#[derive(Debug, Clone)]
enum LineRange {
Single(usize), Range(usize, usize), }
#[derive(Debug, Clone)]
enum UnresolvedLineRange {
Single(i64), Range(i64, i64), }
fn resolve_unresolved_line_range(
unresolved: &UnresolvedLineRange,
total_lines: usize,
) -> Result<LineRange, String> {
match unresolved {
UnresolvedLineRange::Single(line) => {
let resolved = resolve_line_index(*line, total_lines)?;
Ok(LineRange::Single(resolved))
}
UnresolvedLineRange::Range(start, end) => {
let (resolved_start, resolved_end) =
resolve_line_range_batch(*start, *end, total_lines)?;
Ok(LineRange::Range(resolved_start, resolved_end))
}
}
}
pub async fn str_replace_spec(
call: &McpToolCall,
path: &Path,
old_str: &str,
new_str: &str,
) -> Result<McpToolResult> {
if !path.exists() {
return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
"File not found".to_string(),
));
}
let file_lock = acquire_file_lock(path).await?;
let _lock_guard = file_lock.lock().await;
let content = tokio_fs::read_to_string(path)
.await
.map_err(|e| anyhow!("Permission denied. Cannot read file: {}", e))?;
let occurrences = content.matches(old_str).count();
if occurrences == 0 {
return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
"No match found for replacement. Please check your text and try again. Make sure you are not escaping \\\\t, \\\\n or similiar and pass raw content. Alternatively, use line_replace when you know exactly which line to replace.".to_string(),
));
}
if occurrences > 1 {
return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
format!("Found {} matches for replacement text. Please provide more context to make a unique match or use line_replace when you know exactly which line to replace.", occurrences),
));
}
save_file_history(path).await?;
let new_content = content.replace(old_str, new_str);
tokio_fs::write(path, &new_content)
.await
.map_err(|e| anyhow!("Permission denied. Cannot write to file: {}", e))?;
if let Err(e) =
check_and_mark_line_count_change(path, "str_replace", &content, &new_content).await
{
crate::log_debug!("Failed to check line count change: {}", e);
}
Ok(McpToolResult {
tool_name: "text_editor".to_string(),
tool_id: call.tool_id.clone(),
result: json!({
"content": "Successfully replaced text at exactly one location.",
"path": path.to_string_lossy()
}),
})
}
pub async fn insert_text_spec(
call: &McpToolCall,
path: &Path,
insert_line: usize,
new_str: &str,
) -> Result<McpToolResult> {
if let Some(error_result) = validate_line_dependent_operation(path, "insert", call).await? {
return Ok(error_result);
}
if !path.exists() {
return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
"File not found".to_string(),
));
}
let file_lock = acquire_file_lock(path).await?;
let _lock_guard = file_lock.lock().await;
let content = tokio_fs::read_to_string(path)
.await
.map_err(|e| anyhow!("Permission denied. Cannot read file: {}", e))?;
let mut lines: Vec<&str> = content.lines().collect();
if insert_line > lines.len() {
return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
format!(
"Insert line {} exceeds file length ({} lines)",
insert_line,
lines.len()
),
));
}
save_file_history(path).await?;
let new_lines: Vec<&str> = new_str.lines().collect();
let insert_index = insert_line; lines.splice(insert_index..insert_index, new_lines);
let new_content = lines.join("\n");
let final_content = if content.ends_with('\n') {
format!("{}\n", new_content)
} else {
new_content
};
tokio_fs::write(path, &final_content)
.await
.map_err(|e| anyhow!("Permission denied. Cannot write to file: {}", e))?;
if let Err(e) = check_and_mark_line_count_change(path, "insert", &content, &final_content).await
{
crate::log_debug!("Failed to check line count change: {}", e);
}
Ok(McpToolResult {
tool_name: "text_editor".to_string(),
tool_id: call.tool_id.clone(),
result: json!({
"content": format!("Successfully inserted {} lines at line {}", new_str.lines().count(), insert_line),
"path": path.to_string_lossy(),
"lines_inserted": new_str.lines().count()
}),
})
}
pub async fn line_replace_spec(
call: &McpToolCall,
path: &Path,
view_range: (usize, usize),
new_str: &str,
) -> Result<McpToolResult> {
if let Some(error_result) =
validate_line_dependent_operation(path, "line_replace", call).await?
{
return Ok(error_result);
}
if !path.exists() {
return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
"File not found".to_string(),
));
}
if !path.is_file() {
return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
"Path is not a file".to_string(),
));
}
let (start_line, end_line) = view_range;
if new_str.starts_with("\\t") && new_str.contains("\\n") {
return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
"new_str should CONTAIN RAW content not escaped characters".to_string(),
));
}
if start_line == 0 || end_line == 0 {
return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
"Line numbers must be 1-indexed (start from 1)".to_string(),
));
}
if start_line > end_line {
return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
format!(
"start_line ({}) must be less than or equal to end_line ({})",
start_line, end_line
),
));
}
let file_lock = acquire_file_lock(path).await?;
let _lock_guard = file_lock.lock().await;
let file_content = tokio_fs::read_to_string(path)
.await
.map_err(|e| anyhow!("Permission denied. Cannot read file: {}", e))?;
let lines: Vec<&str> = file_content.lines().collect();
if start_line > lines.len() {
return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
format!(
"start_line ({}) exceeds file length ({} lines)",
start_line,
lines.len()
),
));
}
if end_line > lines.len() {
return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
format!(
"end_line ({}) exceeds file length ({} lines)",
end_line,
lines.len()
),
));
}
let safe_end_line = end_line.min(lines.len());
let original_lines: Vec<String> = lines[start_line - 1..safe_end_line]
.iter()
.map(|&line| line.to_string())
.collect();
save_file_history(path).await?;
let mut result_parts: Vec<&str> = Vec::new();
for line in lines.iter().take(start_line - 1) {
result_parts.push(*line);
}
result_parts.push(new_str);
for line in lines.iter().skip(end_line) {
result_parts.push(*line);
}
let line_ending = if file_content.contains("\r\n") {
"\r\n"
} else {
"\n"
};
let new_content = result_parts.join(line_ending);
let final_content = if file_content.ends_with(line_ending) {
format!("{}{}", new_content, line_ending)
} else {
new_content
};
tokio_fs::write(path, &final_content)
.await
.map_err(|e| anyhow!("Permission denied. Cannot write to file: {}", e))?;
if let Err(e) =
check_and_mark_line_count_change(path, "line_replace", &file_content, &final_content).await
{
crate::log_debug!("Failed to check line count change: {}", e);
}
let replaced_snippet = if original_lines.is_empty() {
"(empty range)".to_string()
} else if original_lines.len() == 1 {
format!("{}: {}", start_line, original_lines[0])
} else if original_lines.len() <= 3 {
original_lines
.iter()
.enumerate()
.map(|(i, line)| format!("{}: {}", start_line + i, line))
.collect::<Vec<_>>()
.join("\n")
} else {
format!(
"{}: {}\n... [{} more lines]\n{}: {}",
start_line,
original_lines[0],
original_lines.len() - 2,
start_line + original_lines.len() - 1,
original_lines[original_lines.len() - 1]
)
};
let lines_replaced_count = end_line - start_line + 1;
let new_lines_count = new_str.lines().count();
let content_message = if lines_replaced_count == 1 && new_lines_count == 1 {
format!("Successfully replaced line {} with new content", start_line)
} else if lines_replaced_count == 1 {
format!(
"Successfully replaced line {} with {} lines",
start_line, new_lines_count
)
} else if new_lines_count == 1 {
format!(
"Successfully replaced {} lines ({}-{}) with 1 line",
lines_replaced_count, start_line, end_line
)
} else {
format!(
"Successfully replaced {} lines ({}-{}) with {} lines",
lines_replaced_count, start_line, end_line, new_lines_count
)
};
Ok(McpToolResult {
tool_name: "text_editor".to_string(),
tool_id: call.tool_id.clone(),
result: json!({
"content": content_message,
"path": path.to_string_lossy(),
"lines_replaced": lines_replaced_count,
"new_lines": new_lines_count,
"replaced_snippet": replaced_snippet,
"range": format!("{}-{}", start_line, end_line)
}),
})
}
fn detect_conflicts(operations: &[BatchOperation]) -> Result<(), String> {
for i in 0..operations.len() {
for j in (i + 1)..operations.len() {
let op1 = &operations[i];
let op2 = &operations[j];
let lines1 = get_affected_lines(&op1.line_range);
let lines2 = get_affected_lines(&op2.line_range);
for line1 in &lines1 {
for line2 in &lines2 {
if line1 == line2 {
return Err(format!(
"Conflicting operations: operation {} and {} both affect line {}",
op1.operation_index, op2.operation_index, line1
));
}
}
}
}
}
Ok(())
}
fn get_affected_lines(line_range: &LineRange) -> Vec<usize> {
match line_range {
LineRange::Single(line) => {
vec![*line]
}
LineRange::Range(start, end) => {
(*start..=*end).collect()
}
}
}
async fn apply_batch_operations(
original_content: &str,
operations: &[BatchOperation],
) -> Result<String> {
let mut lines: Vec<String> = original_content.lines().map(|s| s.to_string()).collect();
let mut sorted_ops = operations.to_vec();
sorted_ops.sort_by(|a, b| {
let pos_a = match &a.line_range {
LineRange::Single(line) => *line,
LineRange::Range(start, _) => *start,
};
let pos_b = match &b.line_range {
LineRange::Single(line) => *line,
LineRange::Range(start, _) => *start,
};
pos_b.cmp(&pos_a) });
for operation in sorted_ops {
match operation.operation_type {
OperationType::Insert => {
let insert_after = match operation.line_range {
LineRange::Single(line) => line,
_ => return Err(anyhow!("Insert operation must use single line number")),
};
if insert_after > lines.len() {
return Err(anyhow!(
"Insert position {} is beyond file length {}",
insert_after,
lines.len()
));
}
let content_lines: Vec<String> =
operation.content.lines().map(|s| s.to_string()).collect();
if insert_after == 0 {
for (i, line) in content_lines.into_iter().enumerate() {
lines.insert(i, line);
}
} else {
for (i, line) in content_lines.into_iter().enumerate() {
lines.insert(insert_after + i, line);
}
}
}
OperationType::Replace => {
let (start, end) = match operation.line_range {
LineRange::Range(start, end) => (start, end),
LineRange::Single(line) => (line, line), };
if start == 0 || end == 0 {
return Err(anyhow!("Line numbers must be 1-indexed (start from 1)"));
}
if start > lines.len() || end > lines.len() {
return Err(anyhow!(
"Line range [{}, {}] is beyond file length {}",
start,
end,
lines.len()
));
}
if start > end {
return Err(anyhow!("Invalid line range: start {} > end {}", start, end));
}
let start_idx = start - 1;
let end_idx = end - 1;
for _ in start_idx..=end_idx {
lines.remove(start_idx);
}
let content_lines: Vec<String> =
operation.content.lines().map(|s| s.to_string()).collect();
for (i, line) in content_lines.into_iter().enumerate() {
lines.insert(start_idx + i, line);
}
}
}
}
let result = lines.join("\n");
if original_content.ends_with('\n') && !result.ends_with('\n') {
Ok(format!("{}\n", result))
} else {
Ok(result)
}
}
fn parse_line_range(
value: &Value,
operation_type: &OperationType,
) -> Result<UnresolvedLineRange, String> {
match value {
Value::Number(n) => {
let line = n.as_i64().ok_or("Line number must be an integer")?;
if line == 0 {
return Err("Line numbers are 1-indexed, use 1 for first line".to_string());
}
match operation_type {
OperationType::Insert => Ok(UnresolvedLineRange::Single(line)),
OperationType::Replace => Ok(UnresolvedLineRange::Range(line, line)), }
}
Value::Array(arr) => {
if arr.len() == 1 {
let line = arr[0].as_i64().ok_or("Line number must be an integer")?;
if line == 0 {
return Err("Line numbers are 1-indexed, use 1 for first line".to_string());
}
match operation_type {
OperationType::Insert => Ok(UnresolvedLineRange::Single(line)),
OperationType::Replace => Ok(UnresolvedLineRange::Range(line, line)),
}
} else if arr.len() == 2 {
let start = arr[0].as_i64().ok_or("Start line must be an integer")?;
let end = arr[1].as_i64().ok_or("End line must be an integer")?;
if start == 0 || end == 0 {
return Err("Line numbers are 1-indexed, use 1 for first line".to_string());
}
match operation_type {
OperationType::Insert => Err(
"Insert operation cannot use line range - use single line number"
.to_string(),
),
OperationType::Replace => Ok(UnresolvedLineRange::Range(start, end)),
}
} else {
Err("Line range array must have 1 or 2 elements".to_string())
}
}
_ => Err("Line range must be a number or array".to_string()),
}
}
pub async fn batch_edit_spec(call: &McpToolCall, operations: &[Value]) -> Result<McpToolResult> {
let path_str = match call.parameters.get("path").and_then(|v| v.as_str()) {
Some(p) => p,
None => {
return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
"Missing required 'path' parameter for batch_edit".to_string(),
));
}
};
let path = Path::new(path_str);
if let Some(error_result) = validate_line_dependent_operation(path, "batch_edit", call).await? {
return Ok(error_result);
}
if !path.exists() {
return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
format!("File not found: {}", path_str),
));
}
let file_lock = acquire_file_lock(path).await?;
let _lock_guard = file_lock.lock().await;
let original_content = match tokio_fs::read_to_string(path).await {
Ok(content) => content,
Err(e) => {
return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
format!("Failed to read file '{}': {}", path_str, e),
));
}
};
let mut unresolved_operations = Vec::new();
let mut failed_operations = 0;
let mut operation_details = Vec::new();
for (index, operation) in operations.iter().enumerate() {
let operation_obj = match operation.as_object() {
Some(obj) => obj,
None => {
failed_operations += 1;
operation_details.push(json!({
"operation_index": index,
"status": "failed",
"error": "Operation must be an object"
}));
continue;
}
};
let op_type_str = match operation_obj.get("operation").and_then(|v| v.as_str()) {
Some(op) => op,
None => {
failed_operations += 1;
operation_details.push(json!({
"operation_index": index,
"status": "failed",
"error": "Missing 'operation' field"
}));
continue;
}
};
let operation_type = match op_type_str {
"insert" => OperationType::Insert,
"replace" => OperationType::Replace,
_ => {
failed_operations += 1;
operation_details.push(json!({
"operation_index": index,
"operation": op_type_str,
"status": "failed",
"error": format!("Unsupported operation type: '{}'. Supported operations: insert, replace", op_type_str)
}));
continue;
}
};
let line_range = match operation_obj.get("line_range") {
Some(range_value) => match parse_line_range(range_value, &operation_type) {
Ok(range) => range,
Err(e) => {
failed_operations += 1;
operation_details.push(json!({
"operation_index": index,
"operation": op_type_str,
"status": "failed",
"error": format!("Invalid 'line_range': {}", e)
}));
continue;
}
},
None => {
failed_operations += 1;
operation_details.push(json!({
"operation_index": index,
"operation": op_type_str,
"status": "failed",
"error": "Missing 'line_range' field"
}));
continue;
}
};
let content = match operation_obj.get("content").and_then(|v| v.as_str()) {
Some(c) => c.to_string(),
None => {
failed_operations += 1;
operation_details.push(json!({
"operation_index": index,
"operation": op_type_str,
"status": "failed",
"error": "Missing 'content' field"
}));
continue;
}
};
let unresolved_op = UnresolvedBatchOperation {
operation_type,
line_range: line_range.clone(),
content,
operation_index: index,
};
unresolved_operations.push(unresolved_op);
operation_details.push(json!({
"operation_index": index,
"operation": op_type_str,
"status": "parsed",
"line_range": match &line_range {
UnresolvedLineRange::Single(line) => json!(line),
UnresolvedLineRange::Range(start, end) => json!([start, end]),
}
}));
}
if unresolved_operations.is_empty() {
return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
format!(
"No valid operations found. {} operations failed during parsing.",
failed_operations
),
));
}
let total_lines = original_content.lines().count();
let mut batch_operations = Vec::new();
for unresolved_op in unresolved_operations {
match resolve_unresolved_line_range(&unresolved_op.line_range, total_lines) {
Ok(resolved_range) => {
batch_operations.push(BatchOperation {
operation_type: unresolved_op.operation_type,
line_range: resolved_range,
content: unresolved_op.content,
operation_index: unresolved_op.operation_index,
});
}
Err(err) => {
return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
format!(
"Invalid line range in operation {}: {}",
unresolved_op.operation_index, err
),
));
}
}
}
if let Err(conflict_error) = detect_conflicts(&batch_operations) {
return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
conflict_error,
));
}
let final_content = match apply_batch_operations(&original_content, &batch_operations).await {
Ok(content) => content,
Err(e) => {
return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
format!("Failed to apply operations: {}", e),
));
}
};
save_file_history(path).await?;
if let Err(e) = tokio_fs::write(path, &final_content).await {
return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
format!("Failed to write file '{}': {}", path_str, e),
));
}
if let Err(e) =
check_and_mark_line_count_change(path, "batch_edit", &original_content, &final_content)
.await
{
crate::log_debug!("Failed to check line count change: {}", e);
}
for detail in &mut operation_details {
if detail["status"] == "parsed" {
detail["status"] = json!("success");
}
}
let successful_operations = batch_operations.len();
let total_operations = operations.len();
Ok(McpToolResult::success_with_metadata(
call.tool_name.clone(),
call.tool_id.clone(),
format!(
"Successfully applied {} operations to '{}'. All operations used ORIGINAL line numbers from the file content before any modifications.",
successful_operations, path_str
),
json!({
"path": path_str,
"batch_summary": {
"total_operations": total_operations,
"successful_operations": successful_operations,
"failed_operations": failed_operations,
"overall_success": failed_operations == 0
},
"operation_details": operation_details
}),
))
}