diff --git a/Cargo.lock b/Cargo.lock index bea0a9a..1ed4e4a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1058,11 +1058,23 @@ dependencies = [ "thiserror", ] +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + [[package]] name = "regex-automata" -version = "0.4.11" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "833eb9ce86d40ef33cb1306d8accf7bc8ec2bfea4355cbdebb3df68b40925cad" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" dependencies = [ "aho-corasick", "memchr", @@ -1090,6 +1102,7 @@ dependencies = [ "grep", "infer", "rayon", + "regex", "rust-mcp-sdk", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index c894139..1dae935 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ futures = "0.3" tokio-util = "0.7" async_zip = { version = "0.0", features = ["full"] } grep = "0.3" +regex = "1.11" base64 = "0.22" infer = "0.19.0" rayon = "1.11.0" diff --git a/src/fs_service.rs b/src/fs_service.rs index 8797ae7..ff185d1 100644 --- a/src/fs_service.rs +++ b/src/fs_service.rs @@ -16,7 +16,6 @@ use grep::{ searcher::{BinaryDetection, Searcher, sinks::UTF8}, }; use rayon::iter::{IntoParallelIterator, ParallelBridge, ParallelIterator}; -use rust_mcp_sdk::schema::RpcError; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; use similar::TextDiff; @@ -860,6 +859,7 @@ impl FileSystemService { edits: Vec, dry_run: Option, save_to: Option<&Path>, + line_range: Option, ) -> ServiceResult { let allowed_directories = self.allowed_directories().await; let valid_path = self.validate_path(file_path, allowed_directories)?; @@ -869,121 +869,39 @@ impl FileSystemService { let original_line_ending = self.detect_line_ending(&content_str); let content_str = normalize_line_endings(&content_str); + // Parse line range if provided + let (range_start, range_end) = if let Some(ref range) = line_range { + self.parse_line_range(range, &content_str)? + } else { + (0, content_str.lines().count()) + }; + // Apply edits sequentially let mut modified_content = content_str.clone(); - for edit in edits { - let normalized_old = normalize_line_endings(&edit.old_text); - let normalized_new = normalize_line_endings(&edit.new_text); - // If exact match exists, use it - if modified_content.contains(&normalized_old) { - modified_content = modified_content.replacen(&normalized_old, &normalized_new, 1); - continue; - } + // If line range is specified, work only on that portion + if line_range.is_some() { + let lines: Vec<&str> = content_str.lines().collect(); + let before_lines: Vec<&str> = lines[..range_start].to_vec(); + let target_lines: Vec<&str> = lines[range_start..range_end].to_vec(); + let after_lines: Vec<&str> = lines[range_end..].to_vec(); - // Otherwise, try line-by-line matching with flexibility for whitespace - let old_lines: Vec = normalized_old - .trim_end() - .split('\n') - .map(|s| s.to_string()) - .collect(); + let mut target_content = target_lines.join("\n"); - let content_lines: Vec = modified_content - .trim_end() - .split('\n') - .map(|s| s.to_string()) - .collect(); - - let mut match_found = false; - - // skip when the match is impossible: - if old_lines.len() > content_lines.len() { - let error_message = format!( - "Cannot apply edit: the original text spans more lines ({}) than the file content ({}).", - old_lines.len(), - content_lines.len() - ); - - return Err(RpcError::internal_error() - .with_message(error_message) - .into()); + // Apply edits to the target range + for edit in edits { + target_content = self.apply_single_edit(&target_content, edit)?; } - let max_start = content_lines.len().saturating_sub(old_lines.len()); - for i in 0..=max_start { - let potential_match = &content_lines[i..i + old_lines.len()]; - - // Compare lines with normalized whitespace - let is_match = old_lines.iter().enumerate().all(|(j, old_line)| { - let content_line = &potential_match[j]; - old_line.trim() == content_line.trim() - }); - - if is_match { - // Preserve original indentation of first line - let original_indent = content_lines[i] - .chars() - .take_while(|&c| c.is_whitespace()) - .collect::(); - - let new_lines: Vec = normalized_new - .split('\n') - .enumerate() - .map(|(j, line)| { - // Keep indentation of the first line - if j == 0 { - return format!("{}{}", original_indent, line.trim_start()); - } - - // For subsequent lines, preserve relative indentation and original whitespace type - let old_indent = old_lines - .get(j) - .map(|line| { - line.chars() - .take_while(|&c| c.is_whitespace()) - .collect::() - }) - .unwrap_or_default(); - - let new_indent = line - .chars() - .take_while(|&c| c.is_whitespace()) - .collect::(); - - // Use the same whitespace character as original_indent (tabs or spaces) - let indent_char = if original_indent.contains('\t') { - "\t" - } else { - " " - }; - let relative_indent = if new_indent.len() >= old_indent.len() { - new_indent.len() - old_indent.len() - } else { - 0 // Don't reduce indentation below original - }; - format!( - "{}{}{}", - &original_indent, - &indent_char.repeat(relative_indent), - line.trim_start() - ) - }) - .collect(); - - let mut content_lines = content_lines.clone(); - content_lines.splice(i..i + old_lines.len(), new_lines); - modified_content = content_lines.join("\n"); - match_found = true; - break; - } - } - if !match_found { - return Err(RpcError::internal_error() - .with_message(format!( - "Could not find exact match for edit:\n{}", - edit.old_text - )) - .into()); + // Reconstruct the full content + let mut all_lines = before_lines; + all_lines.extend(target_content.lines().collect::>()); + all_lines.extend(after_lines); + modified_content = all_lines.join("\n"); + } else { + // Apply edits to the entire content + for edit in edits { + modified_content = self.apply_single_edit(&modified_content, edit)?; } } @@ -1034,6 +952,227 @@ impl FileSystemService { escaped } + /// Parse line range string (e.g., "10-50" or "10:50") and return 0-based indices + fn parse_line_range(&self, range: &str, content: &str) -> ServiceResult<(usize, usize)> { + let total_lines = content.lines().count(); + + let parts: Vec<&str> = if range.contains('-') { + range.split('-').collect() + } else if range.contains(':') { + range.split(':').collect() + } else { + return Err(ServiceError::FromString(format!( + "Invalid line range format: '{}'. Expected format: 'start-end' or 'start:end'", + range + ))); + }; + + if parts.len() != 2 { + return Err(ServiceError::FromString(format!( + "Invalid line range format: '{}'. Expected exactly two numbers separated by '-' or ':'", + range + ))); + } + + let start: usize = parts[0] + .trim() + .parse() + .map_err(|_| ServiceError::FromString(format!("Invalid start line number: '{}'", parts[0])))?; + + let end: usize = parts[1] + .trim() + .parse() + .map_err(|_| ServiceError::FromString(format!("Invalid end line number: '{}'", parts[1])))?; + + // Convert to 0-based indexing + let start_idx = start.saturating_sub(1); + let end_idx = end.min(total_lines); + + if start_idx >= end_idx { + return Err(ServiceError::FromString(format!( + "Invalid line range: start ({}) must be less than end ({})", + start, end + ))); + } + + if start_idx >= total_lines { + return Err(ServiceError::FromString(format!( + "Start line ({}) exceeds total lines ({})", + start, total_lines + ))); + } + + Ok((start_idx, end_idx)) + } + + /// Apply a single edit operation to content + fn apply_single_edit(&self, content: &str, edit: EditOperation) -> ServiceResult { + use crate::tools::EditOperation; + + match edit { + EditOperation::Exact { old_text, new_text } => { + self.apply_exact_edit(content, &old_text, &new_text) + } + EditOperation::Regex { pattern, replacement, options } => { + self.apply_regex_edit(content, &pattern, &replacement, options) + } + } + } + + /// Apply exact text replacement (original logic) + fn apply_exact_edit(&self, content: &str, old_text: &str, new_text: &str) -> ServiceResult { + let normalized_old = normalize_line_endings(old_text); + let normalized_new = normalize_line_endings(new_text); + + // If exact match exists, use it + if content.contains(&normalized_old) { + return Ok(content.replacen(&normalized_old, &normalized_new, 1)); + } + + // Otherwise, try line-by-line matching with flexibility for whitespace + let old_lines: Vec = normalized_old + .trim_end() + .split('\n') + .map(|s| s.to_string()) + .collect(); + + let content_lines: Vec = content + .trim_end() + .split('\n') + .map(|s| s.to_string()) + .collect(); + + // skip when the match is impossible: + if old_lines.len() > content_lines.len() { + return Err(ServiceError::FromString(format!( + "Cannot apply edit: the original text spans more lines ({}) than the content ({}).", + old_lines.len(), + content_lines.len() + ))); + } + + let max_start = content_lines.len().saturating_sub(old_lines.len()); + for i in 0..=max_start { + let potential_match = &content_lines[i..i + old_lines.len()]; + + // Compare lines with normalized whitespace + let is_match = old_lines.iter().enumerate().all(|(j, old_line)| { + let content_line = &potential_match[j]; + old_line.trim() == content_line.trim() + }); + + if is_match { + // Preserve original indentation of first line + let original_indent = content_lines[i] + .chars() + .take_while(|&c| c.is_whitespace()) + .collect::(); + + let new_lines: Vec = normalized_new + .split('\n') + .enumerate() + .map(|(j, line)| { + // Keep indentation of the first line + if j == 0 { + return format!("{}{}", original_indent, line.trim_start()); + } + + // For subsequent lines, preserve relative indentation + let old_indent = old_lines + .get(j) + .map(|line| { + line.chars() + .take_while(|&c| c.is_whitespace()) + .collect::() + }) + .unwrap_or_default(); + + let new_indent = line + .chars() + .take_while(|&c| c.is_whitespace()) + .collect::(); + + // Use the same whitespace character as original_indent + let indent_char = if original_indent.contains('\t') { + "\t" + } else { + " " + }; + let relative_indent = if new_indent.len() >= old_indent.len() { + new_indent.len() - old_indent.len() + } else { + 0 + }; + format!( + "{}{}{}", + &original_indent, + &indent_char.repeat(relative_indent), + line.trim_start() + ) + }) + .collect(); + + let mut result_lines = content_lines.clone(); + result_lines.splice(i..i + old_lines.len(), new_lines); + return Ok(result_lines.join("\n")); + } + } + + Err(ServiceError::FromString(format!( + "Could not find exact match for edit:\n{}", + old_text + ))) + } + + /// Apply regex-based replacement + fn apply_regex_edit( + &self, + content: &str, + pattern: &str, + replacement: &str, + options: Option, + ) -> ServiceResult { + use regex::RegexBuilder; + + let opts = options.unwrap_or(crate::tools::RegexEditOptions { + case_insensitive: None, + multiline: None, + dot_all: None, + max_replacements: None, + }); + + // Build regex with options + let regex = RegexBuilder::new(pattern) + .case_insensitive(opts.case_insensitive.unwrap_or(false)) + .multi_line(opts.multiline.unwrap_or(false)) + .dot_matches_new_line(opts.dot_all.unwrap_or(false)) + .build() + .map_err(|e| ServiceError::FromString(format!("Invalid regex pattern: {}", e)))?; + + let max_replacements = opts.max_replacements.unwrap_or(0) as usize; + + let result = if max_replacements == 0 { + // Replace all occurrences + regex.replace_all(content, replacement).to_string() + } else { + // Replace up to max_replacements + let mut result = content.to_string(); + let mut count = 0; + + while count < max_replacements { + let new_result = regex.replace(&result, replacement).to_string(); + if new_result == result { + break; // No more matches + } + result = new_result; + count += 1; + } + result + }; + + Ok(result) + } + // Searches the content of a file for occurrences of the given query string. /// /// This method searches the file specified by `file_path` for lines matching the `query`. diff --git a/src/tools.rs b/src/tools.rs index d7c3c98..9b5d1c9 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -24,7 +24,7 @@ mod zip_unzip; pub use calculate_directory_size::{CalculateDirectorySize, FileSizeOutputFormat}; pub use create_directory::CreateDirectory; pub use directory_tree::DirectoryTree; -pub use edit_file::{EditFile, EditOperation}; +pub use edit_file::{EditFile, EditOperation, RegexEditOptions}; pub use find_duplicate_files::FindDuplicateFiles; pub use find_empty_directories::FindEmptyDirectories; pub use get_file_info::GetFileInfo; diff --git a/src/tools/edit_file.rs b/src/tools/edit_file.rs index 955e1c3..795a229 100644 --- a/src/tools/edit_file.rs +++ b/src/tools/edit_file.rs @@ -7,21 +7,65 @@ use rust_mcp_sdk::schema::{CallToolResult, schema_utils::CallToolError}; use crate::fs_service::FileSystemService; #[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug, JsonSchema)] +#[serde(untagged)] /// Represents a text replacement operation. -pub struct EditOperation { - /// Text to search for - must match exactly. - #[serde(rename = "oldText")] - pub old_text: String, - #[serde(rename = "newText")] - /// Text to replace the matched text with. - pub new_text: String, +/// Supports two modes: exact matching (oldText/newText) or regex matching (pattern/replacement). +pub enum EditOperation { + /// Exact text matching mode + Exact { + /// Text to search for - must match exactly. + #[serde(rename = "oldText")] + old_text: String, + /// Text to replace the matched text with. + #[serde(rename = "newText")] + new_text: String, + }, + /// Regular expression matching mode + Regex { + /// Regular expression pattern to find the text to replace. + pattern: String, + /// Text to replace the matched text with (can use capture groups $1, $2, etc.). + replacement: String, + /// Optional regex options + #[serde(skip_serializing_if = "Option::is_none")] + options: Option, + }, +} + +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug, JsonSchema)] +/// Options for regex-based edits +pub struct RegexEditOptions { + /// If true, the regex is case-insensitive (default: false) + #[serde( + rename = "caseInsensitive", + default, + skip_serializing_if = "Option::is_none" + )] + pub case_insensitive: Option, + /// If true, ^ and $ match line boundaries instead of string boundaries (default: false) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub multiline: Option, + /// If true, the dot (.) matches newlines as well (default: false) + #[serde( + rename = "dotAll", + default, + skip_serializing_if = "Option::is_none" + )] + pub dot_all: Option, + /// Maximum number of replacements (0 = unlimited, default: 0) + #[serde( + rename = "maxReplacements", + default, + skip_serializing_if = "Option::is_none" + )] + pub max_replacements: Option, } #[mcp_tool( name = "edit_file", title="Edit file", - description = concat!("Make line-based edits to a text file. ", - "Each edit replaces exact line sequences with new content. ", + description = concat!("Make line-based edits to a text file with support for exact matching or regular expressions. ", + "Each edit can use either exact text matching (oldText/newText) or regex patterns (pattern/replacement). ", "Returns a git-style diff showing the changes made. ", "Only works within allowed directories."), destructive_hint = false, @@ -43,6 +87,13 @@ pub struct EditFile { skip_serializing_if = "std::option::Option::is_none" )] pub dry_run: Option, + /// Optional line range to restrict edits (format: "start-end" or "start:end") + #[serde( + rename = "lineRange", + default, + skip_serializing_if = "std::option::Option::is_none" + )] + pub line_range: Option, } impl EditFile { @@ -51,7 +102,13 @@ impl EditFile { context: &FileSystemService, ) -> std::result::Result { let diff = context - .apply_file_edits(Path::new(¶ms.path), params.edits, params.dry_run, None) + .apply_file_edits( + Path::new(¶ms.path), + params.edits, + params.dry_run, + None, + params.line_range, + ) .await .map_err(CallToolError::new)?; diff --git a/tests/test_edit_regex.rs b/tests/test_edit_regex.rs new file mode 100644 index 0000000..b449bd7 --- /dev/null +++ b/tests/test_edit_regex.rs @@ -0,0 +1,320 @@ +#[path = "common/common.rs"] +pub mod common; + +use common::create_temp_file; +use common::setup_service; +use rust_mcp_filesystem::tools::{EditOperation, RegexEditOptions}; +use std::fs; + +/// Test simple regex replacement +#[tokio::test] +async fn test_regex_edit_simple() { + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); + let file_path = create_temp_file( + temp_dir.join("dir1").as_path(), + "test.js", + "function test() {\n console.log('hello');\n}", + ); + + let edits = vec![EditOperation::Regex { + pattern: r"function\s+(\w+)".to_string(), + replacement: "async function $1".to_string(), + options: None, + }]; + + let result = service + .apply_file_edits(&file_path, edits, Some(false), None, None) + .await + .unwrap(); + + assert!(result.contains("-function test")); + assert!(result.contains("+async function test")); + + let new_content = tokio::fs::read_to_string(&file_path).await.unwrap(); + assert!(new_content.contains("async function test")); +} + +/// Test regex with case insensitive option +#[tokio::test] +async fn test_regex_edit_case_insensitive() { + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); + let file_path = create_temp_file( + temp_dir.join("dir1").as_path(), + "test.txt", + "Hello World\nHELLO WORLD\nhello world", + ); + + let edits = vec![EditOperation::Regex { + pattern: "hello".to_string(), + replacement: "Hi".to_string(), + options: Some(RegexEditOptions { + case_insensitive: Some(true), + multiline: None, + dot_all: None, + max_replacements: None, + }), + }]; + + let result = service + .apply_file_edits(&file_path, edits, Some(false), None, None) + .await + .unwrap(); + + let new_content = fs::read_to_string(&file_path).unwrap(); + assert_eq!(new_content, "Hi World\nHi WORLD\nHi world"); +} + +/// Test regex with max replacements +#[tokio::test] +async fn test_regex_edit_max_replacements() { + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); + let file_path = create_temp_file( + temp_dir.join("dir1").as_path(), + "test.txt", + "foo bar foo baz foo", + ); + + let edits = vec![EditOperation::Regex { + pattern: "foo".to_string(), + replacement: "FOO".to_string(), + options: Some(RegexEditOptions { + case_insensitive: None, + multiline: None, + dot_all: None, + max_replacements: Some(2), + }), + }]; + + let result = service + .apply_file_edits(&file_path, edits, Some(false), None, None) + .await + .unwrap(); + + let new_content = fs::read_to_string(&file_path).unwrap(); + // Should replace only first 2 occurrences + assert_eq!(new_content, "FOO bar FOO baz foo"); +} + +/// Test regex with multiline mode +#[tokio::test] +async fn test_regex_edit_multiline() { + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); + let file_path = create_temp_file( + temp_dir.join("dir1").as_path(), + "test.txt", + "line1\nline2\nline3", + ); + + let edits = vec![EditOperation::Regex { + pattern: "^line".to_string(), + replacement: "LINE".to_string(), + options: Some(RegexEditOptions { + case_insensitive: None, + multiline: Some(true), + dot_all: None, + max_replacements: None, + }), + }]; + + let result = service + .apply_file_edits(&file_path, edits, Some(false), None, None) + .await + .unwrap(); + + let new_content = fs::read_to_string(&file_path).unwrap(); + assert_eq!(new_content, "LINE1\nLINE2\nLINE3"); +} + +/// Test regex with dot_all mode +#[tokio::test] +async fn test_regex_edit_dot_all() { + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); + let file_path = create_temp_file( + temp_dir.join("dir1").as_path(), + "test.html", + "
hello\nworld
", + ); + + let edits = vec![EditOperation::Regex { + pattern: "
(.*?)
".to_string(), + replacement: "$1".to_string(), + options: Some(RegexEditOptions { + case_insensitive: None, + multiline: None, + dot_all: Some(true), + max_replacements: None, + }), + }]; + + let result = service + .apply_file_edits(&file_path, edits, Some(false), None, None) + .await + .unwrap(); + + let new_content = fs::read_to_string(&file_path).unwrap(); + assert_eq!(new_content, "hello\nworld"); +} + +/// Test mixing exact and regex edits +#[tokio::test] +async fn test_mixed_exact_and_regex_edits() { + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); + let file_path = create_temp_file( + temp_dir.join("dir1").as_path(), + "test.js", + "const version = '1.0.0';\nfunction test() {}", + ); + + let edits = vec![ + EditOperation::Exact { + old_text: "const version = '1.0.0';".to_string(), + new_text: "const version = '2.0.0';".to_string(), + }, + EditOperation::Regex { + pattern: r"function\s+(\w+)".to_string(), + replacement: "async function $1".to_string(), + options: None, + }, + ]; + + let result = service + .apply_file_edits(&file_path, edits, Some(false), None, None) + .await + .unwrap(); + + let new_content = fs::read_to_string(&file_path).unwrap(); + assert!(new_content.contains("const version = '2.0.0';")); + assert!(new_content.contains("async function test")); +} + +/// Test line range with exact edit +#[tokio::test] +async fn test_line_range_exact_edit() { + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); + let file_path = create_temp_file( + temp_dir.join("dir1").as_path(), + "test.txt", + "line1\nline2\nline3\nline4\nline5", + ); + + let edits = vec![EditOperation::Exact { + old_text: "line3".to_string(), + new_text: "LINE3".to_string(), + }]; + + let result = service + .apply_file_edits(&file_path, edits, Some(false), None, Some("2-4".to_string())) + .await + .unwrap(); + + let new_content = fs::read_to_string(&file_path).unwrap(); + assert_eq!(new_content, "line1\nline2\nLINE3\nline4\nline5"); +} + +/// Test line range with regex edit +#[tokio::test] +async fn test_line_range_regex_edit() { + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); + let file_path = create_temp_file( + temp_dir.join("dir1").as_path(), + "test.txt", + "line1\nline2\nline3\nline4\nline5", + ); + + let edits = vec![EditOperation::Regex { + pattern: "line".to_string(), + replacement: "LINE".to_string(), + options: None, + }]; + + // Only apply to lines 2-4 (1-based), should affect line2, line3, line4 + let result = service + .apply_file_edits(&file_path, edits, Some(false), None, Some("2-4".to_string())) + .await + .unwrap(); + + let new_content = fs::read_to_string(&file_path).unwrap(); + assert_eq!(new_content, "line1\nLINE2\nLINE3\nLINE4\nline5"); +} + +/// Test invalid regex pattern +#[tokio::test] +async fn test_regex_edit_invalid_pattern() { + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); + let file_path = create_temp_file( + temp_dir.join("dir1").as_path(), + "test.txt", + "hello world", + ); + + let edits = vec![EditOperation::Regex { + pattern: "[invalid(".to_string(), // Invalid regex + replacement: "test".to_string(), + options: None, + }]; + + let result = service + .apply_file_edits(&file_path, edits, Some(false), None, None) + .await; + + assert!(result.is_err()); +} + +/// Test invalid line range +#[tokio::test] +async fn test_invalid_line_range() { + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); + let file_path = create_temp_file( + temp_dir.join("dir1").as_path(), + "test.txt", + "line1\nline2\nline3", + ); + + let edits = vec![EditOperation::Exact { + old_text: "line2".to_string(), + new_text: "LINE2".to_string(), + }]; + + // Invalid format + let result = service + .apply_file_edits(&file_path, edits.clone(), Some(false), None, Some("invalid".to_string())) + .await; + assert!(result.is_err()); + + // Start >= end + let result = service + .apply_file_edits(&file_path, edits.clone(), Some(false), None, Some("5-2".to_string())) + .await; + assert!(result.is_err()); + + // Start beyond file + let result = service + .apply_file_edits(&file_path, edits, Some(false), None, Some("10-20".to_string())) + .await; + assert!(result.is_err()); +} + +/// Test capture groups in replacement +#[tokio::test] +async fn test_regex_capture_groups() { + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); + let file_path = create_temp_file( + temp_dir.join("dir1").as_path(), + "test.js", + "import React from 'react';", + ); + + let edits = vec![EditOperation::Regex { + pattern: r"import\s+(\w+)\s+from\s+'([^']+)'".to_string(), + replacement: "import { $1 } from '$2'".to_string(), + options: None, + }]; + + let result = service + .apply_file_edits(&file_path, edits, Some(false), None, None) + .await + .unwrap(); + + let new_content = fs::read_to_string(&file_path).unwrap(); + assert_eq!(new_content, "import { React } from 'react';"); +} diff --git a/tests/test_fs_service.rs b/tests/test_fs_service.rs index 9bd7bd3..2f4e182 100644 --- a/tests/test_fs_service.rs +++ b/tests/test_fs_service.rs @@ -342,12 +342,12 @@ async fn test_apply_file_edits() { "test.txt", "line1\nline2\nline3", ); - let edits = vec![EditOperation { + let edits = vec![EditOperation::Exact { old_text: "line2".to_string(), new_text: "line4".to_string(), }]; let result = service - .apply_file_edits(&file_path, edits, Some(false), None) + .apply_file_edits(&file_path, edits, Some(false), None, None) .await .unwrap(); assert!(result.contains("Index:")); @@ -365,12 +365,12 @@ async fn test_apply_file_edits_dry_run() { "test.txt", "line1\nline2\nline3", ); - let edits = vec![EditOperation { + let edits = vec![EditOperation::Exact { old_text: "line2".to_string(), new_text: "line4".to_string(), }]; let result = service - .apply_file_edits(&file_path, edits, Some(true), None) + .apply_file_edits(&file_path, edits, Some(true), None, None) .await .unwrap(); assert!(result.contains("Index:")); @@ -388,14 +388,14 @@ async fn test_apply_file_edits_no_match() { "test.txt", "line1\nline2\nline3", ); - let edits = vec![EditOperation { + let edits = vec![EditOperation::Exact { old_text: "non_existent".to_string(), new_text: "line4".to_string(), }]; let result = service - .apply_file_edits(&file_path, edits, Some(false), None) + .apply_file_edits(&file_path, edits, Some(false), None, None) .await; - assert!(matches!(result, Err(ServiceError::RpcError(_)))); + assert!(matches!(result, Err(ServiceError::FromString(_)))); } #[test] @@ -617,7 +617,7 @@ async fn test_apply_file_edits_mixed_indentation() { "#, ); // different indentation - let edits = vec![EditOperation { + let edits = vec![EditOperation::Exact { old_text: r#"const categories = [ { title: 'Подготовка и исследование', @@ -640,7 +640,7 @@ async fn test_apply_file_edits_mixed_indentation() { let out_file = temp_dir.join("dir1").join("out_indent.txt"); let result = service - .apply_file_edits(&file_path, edits, Some(false), Some(out_file.as_path())) + .apply_file_edits(&file_path, edits, Some(false), Some(out_file.as_path()), None) .await; assert!(result.is_ok()); @@ -665,7 +665,7 @@ async fn test_apply_file_edits_mixed_indentation_2() { "#, ); // different indentation - let edits = vec![EditOperation { + let edits = vec![EditOperation::Exact { old_text: r#"const categories = [ { title: 'Подготовка и исследование', @@ -688,7 +688,7 @@ async fn test_apply_file_edits_mixed_indentation_2() { let out_file = temp_dir.join("dir1").join("out_indent.txt"); let result = service - .apply_file_edits(&file_path, edits, Some(false), Some(out_file.as_path())) + .apply_file_edits(&file_path, edits, Some(false), Some(out_file.as_path()), None) .await; assert!(result.is_ok()); } @@ -703,13 +703,13 @@ async fn test_exact_match() { "hello world\n", ); - let edit = EditOperation { + let edit = EditOperation::Exact { old_text: "hello world".to_string(), new_text: "hello universe".to_string(), }; let result = service - .apply_file_edits(file.as_path(), vec![edit], Some(false), None) + .apply_file_edits(file.as_path(), vec![edit], Some(false), None, None) .await .unwrap(); @@ -727,13 +727,13 @@ async fn test_exact_match_edit2() { "hello world\n", ); - let edits = vec![EditOperation { + let edits = vec![EditOperation::Exact { old_text: "hello world\n".into(), new_text: "hello Rust\n".into(), }]; let result = service - .apply_file_edits(&file, edits, Some(false), None) + .apply_file_edits(&file, edits, Some(false), None, None) .await; assert!(result.is_ok()); @@ -750,13 +750,13 @@ async fn test_line_by_line_match_with_indent() { " let x = 42;\n println!(\"{}\");\n", ); - let edits = vec![EditOperation { + let edits = vec![EditOperation::Exact { old_text: "let x = 42;\nprintln!(\"{}\");\n".into(), new_text: "let x = 43;\nprintln!(\"x = {}\", x)".into(), }]; let result = service - .apply_file_edits(&file, edits, Some(false), None) + .apply_file_edits(&file, edits, Some(false), None, None) .await; assert!(result.is_ok()); @@ -775,13 +775,13 @@ async fn test_dry_run_mode() { "echo hello\n", ); - let edits = vec![EditOperation { + let edits = vec![EditOperation::Exact { old_text: "echo hello\n".into(), new_text: "echo world\n".into(), }]; let result = service - .apply_file_edits(&file, edits, Some(true), None) + .apply_file_edits(&file, edits, Some(true), None, None) .await; assert!(result.is_ok()); @@ -800,13 +800,13 @@ async fn test_save_to_different_path() { let save_to = temp_dir.as_path().join("dir1").join("saved_output.txt"); - let edits = vec![EditOperation { + let edits = vec![EditOperation::Exact { old_text: "foo = 1\n".into(), new_text: "foo = 2\n".into(), }]; let result = service - .apply_file_edits(&orig_file, edits, Some(false), Some(&save_to)) + .apply_file_edits(&orig_file, edits, Some(false), Some(&save_to), None) .await; assert!(result.is_ok()); @@ -826,13 +826,13 @@ async fn test_diff_backtick_formatting() { "```\nhello\n```\n", ); - let edits = vec![EditOperation { + let edits = vec![EditOperation::Exact { old_text: "```\nhello\n```".into(), new_text: "```\nworld\n```".into(), }]; let result = service - .apply_file_edits(&file, edits, Some(true), None) + .apply_file_edits(&file, edits, Some(true), None, None) .await; assert!(result.is_ok()); @@ -851,7 +851,7 @@ async fn test_no_edits_provided() { ); let result = service - .apply_file_edits(&file, vec![], Some(false), None) + .apply_file_edits(&file, vec![], Some(false), None, None) .await; assert!(result.is_ok()); @@ -868,13 +868,13 @@ async fn test_preserve_windows_line_endings() { "line1\r\nline2\r\n", ); - let edits = vec![EditOperation { + let edits = vec![EditOperation::Exact { old_text: "line1\nline2".into(), // normalized format new_text: "updated1\nupdated2".into(), }]; let result = service - .apply_file_edits(&file, edits, Some(false), None) + .apply_file_edits(&file, edits, Some(false), None, None) .await; assert!(result.is_ok()); @@ -891,13 +891,13 @@ async fn test_preserve_unix_line_endings() { "line1\nline2\n", ); - let edits = vec![EditOperation { + let edits = vec![EditOperation::Exact { old_text: "line1\nline2".into(), new_text: "updated1\nupdated2".into(), }]; let result = service - .apply_file_edits(&file, edits, Some(false), None) + .apply_file_edits(&file, edits, Some(false), None, None) .await; assert!(result.is_ok()); @@ -912,7 +912,7 @@ async fn test_panic_on_out_of_bounds_edit() { let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); // Set up an edit that expects to match 5 lines - let edit = EditOperation { + let edit = EditOperation::Exact { old_text: "line e\n".repeat(41).to_string(), new_text: "replaced content".to_string(), }; @@ -926,7 +926,7 @@ async fn test_panic_on_out_of_bounds_edit() { ); let result = service - .apply_file_edits(&test_path, vec![edit], Some(true), None) + .apply_file_edits(&test_path, vec![edit], Some(true), None, None) .await; // It should panic without the fix, or return an error after applying the fix