Files
tkmind_go/crates/goose-mcp/src/developer/text_editor.rs
T
2026-01-06 14:13:20 -05:00

1107 lines
34 KiB
Rust

use anyhow::Result;
use indoc::formatdoc;
use mpatch::{apply_patch, parse_diffs, PatchError};
use std::{
collections::HashMap,
fs::File,
io::Read,
path::{Path, PathBuf},
};
use url::Url;
use rmcp::model::{Content, ErrorCode, ErrorData, Role};
use super::editor_models::EditorModel;
use super::lang;
use super::shell::normalize_line_endings;
// Constants
pub const LINE_READ_LIMIT: usize = 2000;
pub const MAX_DIFF_SIZE: usize = 1024 * 1024; // 1MB max diff size
pub const MAX_FILES_IN_DIFF: usize = 100; // Maximum files in a multi-file diff
/// Validates paths to prevent directory traversal attacks
fn validate_path_safety(base_dir: &Path, target_path: &Path) -> Result<(), ErrorData> {
// Check for .. components
if target_path
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
return Err(ErrorData::new(
ErrorCode::INVALID_PARAMS,
"Path traversal detected: paths cannot contain '..'".to_string(),
None,
));
}
// Try to canonicalize and check if within base
if let (Ok(canonical_target), Ok(canonical_base)) =
(target_path.canonicalize(), base_dir.canonicalize())
{
if !canonical_target.starts_with(&canonical_base) {
return Err(ErrorData::new(
ErrorCode::INVALID_PARAMS,
format!(
"Path '{}' is outside the base directory",
target_path.display()
),
None,
));
}
} else if !target_path.exists() {
// For new files, check parent directory
if let Some(parent) = target_path.parent() {
if let (Ok(canonical_parent), Ok(canonical_base)) =
(parent.canonicalize(), base_dir.canonicalize())
{
if !canonical_parent.starts_with(&canonical_base) {
return Err(ErrorData::new(
ErrorCode::INVALID_PARAMS,
format!(
"Path '{}' would be outside the base directory",
target_path.display()
),
None,
));
}
}
}
}
// Check for symlinks
if target_path.exists() {
let metadata = target_path.symlink_metadata().map_err(|e| {
ErrorData::new(
ErrorCode::INTERNAL_ERROR,
format!("Failed to check symlink status: {}", e),
None,
)
})?;
if metadata.is_symlink() {
return Err(ErrorData::new(
ErrorCode::INVALID_PARAMS,
format!(
"Cannot modify symlink '{}'. Please operate on the actual file.",
target_path.display()
),
None,
));
}
}
Ok(())
}
/// Results from applying a diff
#[derive(Debug, Default)]
pub struct DiffResults {
files_created: usize,
files_modified: usize,
files_deleted: usize,
lines_added: usize,
lines_removed: usize,
}
/// Validates the size of the diff content
fn validate_diff_size(diff_content: &str) -> Result<(), ErrorData> {
if diff_content.len() > MAX_DIFF_SIZE {
return Err(ErrorData::new(
ErrorCode::INVALID_PARAMS,
format!(
"Diff is too large ({} bytes). Maximum size is {} bytes (1MB).",
diff_content.len(),
MAX_DIFF_SIZE
),
None,
));
}
Ok(())
}
/// Counts line changes from the diff content
fn count_line_changes(diff_content: &str) -> (usize, usize) {
let lines_added = diff_content
.lines()
.filter(|l| l.starts_with('+') && !l.starts_with("+++"))
.count();
let lines_removed = diff_content
.lines()
.filter(|l| l.starts_with('-') && !l.starts_with("---"))
.count();
(lines_added, lines_removed)
}
/// Generates the summary for the diff application
fn generate_summary(results: &DiffResults, is_single_file: bool, base_path: &Path) -> Vec<Content> {
let summary = if is_single_file {
format!(
"Successfully applied diff to {}:\n• Lines added: {}\n• Lines removed: {}",
base_path.display(),
results.lines_added,
results.lines_removed
)
} else if results.files_created + results.files_modified + results.files_deleted > 1 {
format!(
"Successfully applied multi-file diff:\n\
• Files created: {}\n\
• Files modified: {}\n\
• Files deleted: {}\n\
• Lines added: {}\n\
• Lines removed: {}",
results.files_created,
results.files_modified,
results.files_deleted,
results.lines_added,
results.lines_removed
)
} else {
format!(
"Successfully applied diff:\n\
• Files created: {}\n\
• Files modified: {}\n\
• Files deleted: {}\n\
• Lines added: {}\n\
• Lines removed: {}",
results.files_created,
results.files_modified,
results.files_deleted,
results.lines_added,
results.lines_removed
)
};
let user_message = if is_single_file {
format!("{}\n\nUse 'undo_edit' to revert if needed.\n\n", summary)
} else {
format!(
"{}\n\nUse 'undo_edit' on individual files to revert if needed.\n\n",
summary
)
};
vec![
Content::text(summary.clone()).with_audience(vec![Role::Assistant]),
Content::text(user_message)
.with_audience(vec![Role::User])
.with_priority(0.2),
]
}
fn adjust_base_dir_for_overlap(base_dir: &Path, file_path: &Path) -> PathBuf {
let base_components: Vec<_> = base_dir.components().collect();
let file_components: Vec<_> = file_path.components().collect();
let min_len = base_components.len().min(file_components.len());
let max_k = (1..=min_len)
.rfind(|&k| file_components[0..k] == base_components[base_components.len() - k..])
.unwrap_or(0);
if max_k > 0 {
let adjusted_components = base_components[..base_components.len() - max_k].to_vec();
PathBuf::from_iter(adjusted_components)
} else {
base_dir.to_path_buf()
}
}
/// Applies a single patch and updates results
fn apply_single_patch(
patch: &mpatch::Patch,
base_dir: &Path,
file_history: &std::sync::Arc<std::sync::Mutex<HashMap<PathBuf, Vec<String>>>>,
results: &mut DiffResults,
failed_hunks: &mut Vec<String>,
) -> Result<(), ErrorData> {
let adjusted_base_dir = adjust_base_dir_for_overlap(base_dir, &patch.file_path);
let file_path = adjusted_base_dir.join(&patch.file_path);
// Validate path safety
validate_path_safety(&adjusted_base_dir, &file_path)?;
// Save history before modifying
let file_existed = file_path.exists();
if file_existed {
save_file_history(&file_path, file_history)?;
}
// Apply patch with fuzzy matching (70% similarity threshold)
let success = apply_patch(patch, &adjusted_base_dir, false, 0.7).map_err(|e| match e {
PatchError::Io { path, source } => ErrorData::new(
ErrorCode::INTERNAL_ERROR,
format!("Failed to process '{}': {}", path.display(), source),
None,
),
PatchError::PathTraversal(path) => ErrorData::new(
ErrorCode::INVALID_PARAMS,
format!(
"Security: Path '{}' would escape the base directory",
path.display()
),
None,
),
PatchError::TargetNotFound(path) => ErrorData::new(
ErrorCode::INTERNAL_ERROR,
format!(
"File '{}' not found and patch doesn't create it",
path.display()
),
None,
),
PatchError::MissingFileHeader => ErrorData::new(
ErrorCode::INVALID_PARAMS,
"Invalid patch format".to_string(),
None,
),
})?;
if !success {
// Collect information about failed hunks for better error reporting
let hunk_count = patch.hunks.len();
let context_preview = patch
.hunks
.first()
.and_then(|h| {
let match_block = h.get_match_block();
match_block.first().map(|s| s.to_string())
})
.unwrap_or_else(|| "(empty context)".to_string());
failed_hunks.push(format!(
"Failed to apply some hunks to '{}' ({} hunks total). First expected line: '{}'",
patch.file_path.display(),
hunk_count,
context_preview
));
}
// Update statistics
if file_existed {
results.files_modified += 1;
} else {
results.files_created += 1;
}
Ok(())
}
/// Parses diff content into patches with proper error handling
fn parse_diff_content(diff_content: &str) -> Result<Vec<mpatch::Patch>, ErrorData> {
let wrapped_diff = if diff_content.contains("```diff") || diff_content.contains("```patch") {
diff_content.to_string()
} else {
format!("```diff\n{}\n```", diff_content)
};
parse_diffs(&wrapped_diff).map_err(|e| match e {
PatchError::MissingFileHeader => ErrorData::new(
ErrorCode::INVALID_PARAMS,
"Invalid diff format: Missing file header (e.g., '--- a/path/to/file')".to_string(),
None,
),
PatchError::Io { path, source } => ErrorData::new(
ErrorCode::INTERNAL_ERROR,
format!("I/O error processing {}: {}", path.display(), source),
None,
),
PatchError::PathTraversal(path) => ErrorData::new(
ErrorCode::INVALID_PARAMS,
format!(
"Security: Path '{}' would escape the base directory",
path.display()
),
None,
),
PatchError::TargetNotFound(path) => ErrorData::new(
ErrorCode::INTERNAL_ERROR,
format!("Target file not found: {}", path.display()),
None,
),
})
}
/// Ensures all patched files end with a newline
fn ensure_trailing_newlines(patches: &[mpatch::Patch], base_dir: &Path) -> Result<(), ErrorData> {
for patch in patches {
let adjusted_base_dir = adjust_base_dir_for_overlap(base_dir, &patch.file_path);
let file_path = adjusted_base_dir.join(&patch.file_path);
if file_path.exists() {
let content = std::fs::read_to_string(&file_path).map_err(|e| {
ErrorData::new(
ErrorCode::INTERNAL_ERROR,
format!("Failed to read file for post-processing: {}", e),
None,
)
})?;
if !content.ends_with('\n') {
let content_with_newline = format!("{}\n", content);
std::fs::write(&file_path, content_with_newline).map_err(|e| {
ErrorData::new(
ErrorCode::INTERNAL_ERROR,
format!("Failed to add trailing newline: {}", e),
None,
)
})?;
}
}
}
Ok(())
}
/// Reports partial failures from patch application
fn report_partial_failures(failed_hunks: &[String]) {
if !failed_hunks.is_empty() {
let error_msg = format!(
"Some patches were only partially applied (fuzzy matching at 70% similarity):\n\n{}\n\n\
The files have been modified but some hunks couldn't find their context.\n\
This usually happens when:\n\
• The file has changed significantly from when the diff was created\n\
• Line numbers in the diff are incorrect\n\
• The context lines don't match exactly\n\n\
Review the changes and use 'undo_edit' if needed.",
failed_hunks.join("\n")
);
tracing::warn!("{}", error_msg);
}
}
/// Applies any diff (single or multi-file) using mpatch for fuzzy matching
pub async fn apply_diff(
base_path: &Path,
diff_content: &str,
file_history: &std::sync::Arc<std::sync::Mutex<HashMap<PathBuf, Vec<String>>>>,
) -> Result<Vec<Content>, ErrorData> {
validate_diff_size(diff_content)?;
let patches = parse_diff_content(diff_content)?;
if patches.len() > MAX_FILES_IN_DIFF {
return Err(ErrorData::new(
ErrorCode::INVALID_PARAMS,
format!(
"Too many files in diff ({}). Maximum is {} files.",
patches.len(),
MAX_FILES_IN_DIFF
),
None,
));
}
let base_dir = if base_path.is_file() {
base_path.parent().unwrap_or(Path::new(".")).to_path_buf()
} else {
base_path.to_path_buf()
};
let mut results = DiffResults::default();
let mut failed_hunks = Vec::new();
for patch in &patches {
apply_single_patch(
patch,
&base_dir,
file_history,
&mut results,
&mut failed_hunks,
)?;
}
ensure_trailing_newlines(&patches, &base_dir)?;
report_partial_failures(&failed_hunks);
let (lines_added, lines_removed) = count_line_changes(diff_content);
results.lines_added = lines_added;
results.lines_removed = lines_removed;
let is_single_file = patches.len() == 1;
Ok(generate_summary(&results, is_single_file, base_path))
}
// Helper method to validate and calculate view range indices
pub fn calculate_view_range(
view_range: Option<(usize, i64)>,
total_lines: usize,
) -> Result<(usize, usize), ErrorData> {
if let Some((start_line, end_line)) = view_range {
// Convert 1-indexed line numbers to 0-indexed
let start_idx = if start_line > 0 { start_line - 1 } else { 0 };
let end_idx = if end_line == -1 {
total_lines
} else {
std::cmp::min(end_line as usize, total_lines)
};
if start_idx >= total_lines {
return Err(ErrorData::new(
ErrorCode::INVALID_PARAMS,
format!(
"Start line {} is beyond the end of the file (total lines: {})",
start_line, total_lines
),
None,
));
}
if start_idx >= end_idx {
return Err(ErrorData::new(
ErrorCode::INVALID_PARAMS,
format!(
"Start line {} must be less than end line {}",
start_line, end_line
),
None,
));
}
Ok((start_idx, end_idx))
} else {
Ok((0, total_lines))
}
}
// Helper method to format file content with line numbers
pub fn format_file_content(
path: &Path,
lines: &[&str],
start_idx: usize,
end_idx: usize,
view_range: Option<(usize, i64)>,
) -> String {
let display_content = if lines.is_empty() {
String::new()
} else {
let selected_lines: Vec<String> = lines[start_idx..end_idx]
.iter()
.enumerate()
.map(|(i, line)| format!("{}: {}", start_idx + i + 1, line))
.collect();
selected_lines.join("\n")
};
let language = lang::get_language_identifier(path);
if let Some((start, end)) = view_range {
formatdoc! {"
### {path} (lines {start}-{end})
```{language}
{content}
```
",
path=path.display(),
start=start,
end=if end == -1 { "end".to_string() } else { end.to_string() },
language=language,
content=display_content,
}
} else {
formatdoc! {"
### {path}
```{language}
{content}
```
",
path=path.display(),
language=language,
content=display_content,
}
}
}
pub fn recommend_read_range(path: &Path, total_lines: usize) -> Result<Vec<Content>, ErrorData> {
Err(ErrorData::new(ErrorCode::INTERNAL_ERROR, format!(
"File '{}' is {} lines long, recommended to read in with view_range (or searching) to get bite size content. If you do wish to read all the file, please pass in view_range with [1, {}] to read it all at once",
path.display(),
total_lines,
total_lines
), None))
}
/// Lists the contents of a directory with a maximum number of items
fn list_directory_contents(path: &Path) -> Result<Vec<Content>, ErrorData> {
const MAX_ITEMS: usize = 50; // Maximum number of items to display
// List files in the directory (similar to ls output)
let entries = std::fs::read_dir(path).map_err(|e| {
ErrorData::new(
ErrorCode::INTERNAL_ERROR,
format!("Failed to read directory: {}", e),
None,
)
})?;
let mut files = Vec::new();
let mut dirs = Vec::new();
let mut total_count = 0;
for entry in entries {
let entry = entry.map_err(|e| {
ErrorData::new(
ErrorCode::INTERNAL_ERROR,
format!("Failed to read directory entry: {}", e),
None,
)
})?;
total_count += 1;
// Only process up to MAX_ITEMS entries
if dirs.len() + files.len() < MAX_ITEMS {
let metadata = entry.metadata().map_err(|e| {
ErrorData::new(
ErrorCode::INTERNAL_ERROR,
format!("Failed to read metadata: {}", e),
None,
)
})?;
let name = entry.file_name().to_string_lossy().to_string();
if metadata.is_dir() {
dirs.push(format!("{}/", name));
} else {
files.push(name);
}
}
}
// Sort for consistent output
dirs.sort();
files.sort();
let mut output = format!("'{}' is a directory. Contents:\n\n", path.display());
if !dirs.is_empty() {
output.push_str("Directories:\n");
for dir in &dirs {
output.push_str(&format!(" {}\n", dir));
}
output.push('\n');
}
if !files.is_empty() {
output.push_str("Files:\n");
for file in &files {
output.push_str(&format!(" {}\n", file));
}
}
if dirs.is_empty() && files.is_empty() {
output.push_str(" (empty directory)\n");
}
// If we hit the limit, indicate there are more items
if total_count > MAX_ITEMS {
output.push_str(&format!(
"\n... and {} more items (showing first {} items)\n",
total_count - MAX_ITEMS,
MAX_ITEMS
));
}
Ok(vec![Content::text(output)])
}
pub async fn text_editor_view(
path: &PathBuf,
view_range: Option<(usize, i64)>,
) -> Result<Vec<Content>, ErrorData> {
// Check if path is a directory
if path.is_dir() {
return list_directory_contents(path);
}
if !path.is_file() {
return Err(ErrorData::new(
ErrorCode::INTERNAL_ERROR,
format!(
"The path '{}' does not exist or is not accessible.",
path.display()
),
None,
));
}
const MAX_FILE_SIZE: u64 = 400 * 1024; // 400KB
let f = File::open(path).map_err(|e| {
ErrorData::new(
ErrorCode::INTERNAL_ERROR,
format!("Failed to open file: {}", e),
None,
)
})?;
let file_size = f
.metadata()
.map_err(|e| {
ErrorData::new(
ErrorCode::INTERNAL_ERROR,
format!("Failed to get file metadata: {}", e),
None,
)
})?
.len();
if file_size > MAX_FILE_SIZE {
return Err(ErrorData::new(
ErrorCode::INTERNAL_ERROR,
format!(
"File '{}' is too large ({:.2}KB). Maximum size is 400KB to prevent memory issues.",
path.display(),
file_size as f64 / 1024.0
),
None,
));
}
// Ensure we never read over that limit even if the file is being concurrently mutated
let mut f = f.take(MAX_FILE_SIZE);
let uri = Url::from_file_path(path)
.map_err(|_| {
ErrorData::new(
ErrorCode::INTERNAL_ERROR,
"Invalid file path".to_string(),
None,
)
})?
.to_string();
let mut content = String::new();
f.read_to_string(&mut content).map_err(|e| {
ErrorData::new(
ErrorCode::INTERNAL_ERROR,
format!("Failed to read file: {}", e),
None,
)
})?;
let lines: Vec<&str> = content.lines().collect();
let total_lines = lines.len();
// We will gently encourage the LLM to specify a range for large line count files
// it can of course specify exact range to read any size file
if view_range.is_none() && total_lines > LINE_READ_LIMIT {
return recommend_read_range(path, total_lines);
}
let (start_idx, end_idx) = calculate_view_range(view_range, total_lines)?;
let formatted = format_file_content(path, &lines, start_idx, end_idx, view_range);
// The LLM gets just a quick update as we expect the file to view in the status
// but we send a low priority message for the human
Ok(vec![
Content::embedded_text(uri, content).with_audience(vec![Role::Assistant]),
Content::text(formatted)
.with_audience(vec![Role::User])
.with_priority(0.0),
])
}
pub async fn text_editor_write(path: &PathBuf, file_text: &str) -> Result<Vec<Content>, ErrorData> {
// Normalize line endings based on platform
let mut normalized_text = normalize_line_endings(file_text); // Make mutable
// Ensure the text ends with a newline
if !normalized_text.ends_with('\n') {
normalized_text.push('\n');
}
// Write to the file
std::fs::write(path, &normalized_text) // Write the potentially modified text
.map_err(|e| {
ErrorData::new(
ErrorCode::INTERNAL_ERROR,
format!("Failed to write file: {}", e),
None,
)
})?;
// Try to detect the language from the file extension
let language = lang::get_language_identifier(path);
// The assistant output does not show the file again because the content is already in the tool request
// but we do show it to the user here, using the final written content
Ok(vec![
Content::text(format!("Successfully wrote to {}", path.display()))
.with_audience(vec![Role::Assistant]),
Content::text(formatdoc! {
r#"
### {path}
```{language}
{content}
```
"#,
path=path.display(),
language=language,
content=&normalized_text // Use the final normalized_text for user feedback
})
.with_audience(vec![Role::User])
.with_priority(0.2),
])
}
#[allow(clippy::too_many_lines)]
pub async fn text_editor_replace(
path: &PathBuf,
old_str: &str,
new_str: &str,
diff: Option<&str>,
editor_model: &Option<EditorModel>,
file_history: &std::sync::Arc<
std::sync::Mutex<std::collections::HashMap<PathBuf, Vec<String>>>,
>,
) -> Result<Vec<Content>, ErrorData> {
// Check if diff is provided
if let Some(diff_content) = diff {
// Validate it's a proper diff
if !diff_content.contains("---") || !diff_content.contains("+++") {
return Err(ErrorData::new(
ErrorCode::INVALID_PARAMS,
"The 'diff' parameter must be in unified diff format".to_string(),
None,
));
}
return apply_diff(path, diff_content, file_history).await;
}
// Check if file exists and is active
if !path.exists() {
return Err(ErrorData::new(
ErrorCode::INVALID_PARAMS,
format!(
"File '{}' does not exist, you can write a new file with the `write` command",
path.display()
),
None,
));
}
// Read content
let content = std::fs::read_to_string(path).map_err(|e| {
ErrorData::new(
ErrorCode::INTERNAL_ERROR,
format!("Failed to read file: {}", e),
None,
)
})?;
// Check if Editor API is configured and use it as the primary path
if let Some(ref editor) = editor_model {
// Editor API path - save history then call API directly
save_file_history(path, file_history)?;
match editor.edit_code(&content, old_str, new_str).await {
Ok(updated_content) => {
// Write the updated content directly
let mut normalized_content = normalize_line_endings(&updated_content);
if !normalized_content.ends_with('\n') {
normalized_content.push('\n');
}
std::fs::write(path, &normalized_content).map_err(|e| {
ErrorData::new(
ErrorCode::INTERNAL_ERROR,
format!("Failed to write file: {}", e),
None,
)
})?;
// Simple success message for Editor API
return Ok(vec![
Content::text(format!("Successfully edited {}", path.display()))
.with_audience(vec![Role::Assistant]),
Content::text(format!("File {} has been edited", path.display()))
.with_audience(vec![Role::User])
.with_priority(0.2),
]);
}
Err(e) => {
tracing::debug!(
"Editor API call failed: {}, falling back to string replacement",
e
);
// Fall through to traditional path below
}
}
}
// Traditional string replacement path (original logic)
// Ensure 'old_str' appears exactly once
if content.matches(old_str).count() > 1 {
return Err(ErrorData::new(
ErrorCode::INVALID_PARAMS,
"'old_str' must appear exactly once in the file, but it appears multiple times"
.to_string(),
None,
));
}
if content.matches(old_str).count() == 0 {
return Err(ErrorData::new(ErrorCode::INVALID_PARAMS, "'old_str' must appear exactly once in the file, but it does not appear in the file. Make sure the string exactly matches existing file content, including whitespace!".to_string(), None));
}
// Save history for undo (original behavior - after validation)
save_file_history(path, file_history)?;
let new_content = content.replace(old_str, new_str);
let mut normalized_content = normalize_line_endings(&new_content);
if !normalized_content.ends_with('\n') {
normalized_content.push('\n');
}
std::fs::write(path, &normalized_content).map_err(|e| {
ErrorData::new(
ErrorCode::INTERNAL_ERROR,
format!("Failed to write file: {}", e),
None,
)
})?;
// Try to detect the language from the file extension
let language = lang::get_language_identifier(path);
// Show a snippet of the changed content with context
const SNIPPET_LINES: usize = 4;
// Count newlines before the replacement to find the line number
let replacement_line = content
.split(old_str)
.next()
.expect("should split on already matched content")
.matches('\n')
.count();
// Calculate start and end lines for the snippet
let start_line = replacement_line.saturating_sub(SNIPPET_LINES);
let end_line = replacement_line + SNIPPET_LINES + new_content.matches('\n').count();
// Get the relevant lines for our snippet
let lines: Vec<&str> = new_content.lines().collect();
let snippet = lines
.iter()
.skip(start_line)
.take(end_line - start_line + 1)
.cloned()
.collect::<Vec<&str>>()
.join("\n");
let output = formatdoc! {r#"
```{language}
{snippet}
```
"#,
language=language,
snippet=snippet
};
let success_message = formatdoc! {r#"
The file {} has been edited, and the section now reads:
{}
Review the changes above for errors. Undo and edit the file again if necessary!
"#,
path.display(),
output
};
Ok(vec![
Content::text(success_message).with_audience(vec![Role::Assistant]),
Content::text(output)
.with_audience(vec![Role::User])
.with_priority(0.2),
])
}
pub async fn text_editor_insert(
path: &PathBuf,
insert_line_spec: i64,
new_str: &str,
file_history: &std::sync::Arc<
std::sync::Mutex<std::collections::HashMap<PathBuf, Vec<String>>>,
>,
) -> Result<Vec<Content>, ErrorData> {
// Check if file exists
if !path.exists() {
return Err(ErrorData::new(
ErrorCode::INVALID_PARAMS,
format!(
"File '{}' does not exist, you can write a new file with the `write` command",
path.display()
),
None,
));
}
// Read content
let content = std::fs::read_to_string(path).map_err(|e| {
ErrorData::new(
ErrorCode::INTERNAL_ERROR,
format!("Failed to read file: {}", e),
None,
)
})?;
// Save history for undo
save_file_history(path, file_history)?;
let lines: Vec<&str> = content.lines().collect();
let total_lines = lines.len();
// Allow insert_line to be negative
let insert_line = if insert_line_spec < 0 {
// -1 == end of file, -2 == before the last line, etc.
(total_lines as i64 + 1 + insert_line_spec) as usize
} else {
insert_line_spec as usize
};
// Validate insert_line parameter
if insert_line > total_lines {
return Err(ErrorData::new(ErrorCode::INVALID_PARAMS, format!(
"Insert line {} is beyond the end of the file (total lines: {}). Use 0 to insert at the beginning or {} to insert at the end.",
insert_line, total_lines, total_lines
), None));
}
// Create new content with inserted text
let mut new_lines = Vec::new();
// Add lines before the insertion point
for (i, line) in lines.iter().enumerate() {
if i == insert_line {
// Insert the new text at this position
new_lines.push(new_str.to_string());
}
new_lines.push(line.to_string());
}
// If inserting at the end (after all existing lines)
if insert_line == total_lines {
new_lines.push(new_str.to_string());
}
let new_content = new_lines.join("\n");
let normalized_content = normalize_line_endings(&new_content);
// Ensure the file ends with a newline
let final_content = if !normalized_content.ends_with('\n') {
format!("{}\n", normalized_content)
} else {
normalized_content
};
std::fs::write(path, &final_content).map_err(|e| {
ErrorData::new(
ErrorCode::INTERNAL_ERROR,
format!("Failed to write file: {}", e),
None,
)
})?;
// Try to detect the language from the file extension
let language = lang::get_language_identifier(path);
// Show a snippet of the inserted content with context
const SNIPPET_LINES: usize = 4;
let insertion_line = insert_line + 1; // Convert to 1-indexed for display
// Calculate start and end lines for the snippet
let start_line = insertion_line.saturating_sub(SNIPPET_LINES);
let end_line = std::cmp::min(insertion_line + SNIPPET_LINES, new_lines.len());
// Get the relevant lines for our snippet with line numbers
let snippet_lines: Vec<String> = new_lines[start_line.saturating_sub(1)..end_line]
.iter()
.enumerate()
.map(|(i, line)| format!("{}: {}", start_line + i, line))
.collect();
let snippet = snippet_lines.join("\n");
let output = formatdoc! {r#"
```{language}
{snippet}
```
"#,
language=language,
snippet=snippet
};
let success_message = formatdoc! {r#"
Text has been inserted at line {} in {}. The section now reads:
{}
Review the changes above for errors. Undo and edit the file again if necessary!
"#,
insertion_line,
path.display(),
output
};
Ok(vec![
Content::text(success_message).with_audience(vec![Role::Assistant]),
Content::text(output)
.with_audience(vec![Role::User])
.with_priority(0.2),
])
}
pub async fn text_editor_undo(
path: &PathBuf,
file_history: &std::sync::Arc<
std::sync::Mutex<std::collections::HashMap<PathBuf, Vec<String>>>,
>,
) -> Result<Vec<Content>, ErrorData> {
let mut history = file_history.lock().unwrap();
if let Some(contents) = history.get_mut(path) {
if let Some(previous_content) = contents.pop() {
// Write previous content back to file
std::fs::write(path, previous_content).map_err(|e| {
ErrorData::new(
ErrorCode::INTERNAL_ERROR,
format!("Failed to write file: {}", e),
None,
)
})?;
Ok(vec![Content::text("Undid the last edit")])
} else {
Err(ErrorData::new(
ErrorCode::INVALID_PARAMS,
"No edit history available to undo".to_string(),
None,
))
}
} else {
Err(ErrorData::new(
ErrorCode::INVALID_PARAMS,
"No edit history available to undo".to_string(),
None,
))
}
}
pub fn save_file_history(
path: &PathBuf,
file_history: &std::sync::Arc<
std::sync::Mutex<std::collections::HashMap<PathBuf, Vec<String>>>,
>,
) -> Result<(), ErrorData> {
let mut history = file_history.lock().unwrap();
let content = if path.exists() {
std::fs::read_to_string(path).map_err(|e| {
ErrorData::new(
ErrorCode::INTERNAL_ERROR,
format!("Failed to read file: {}", e),
None,
)
})?
} else {
String::new()
};
history.entry(path.clone()).or_default().push(content);
Ok(())
}