From 520033fc35cd329d65a472011250acdae58d55fd Mon Sep 17 00:00:00 2001 From: Ali Hashemi Date: Sat, 24 May 2025 17:41:26 -0300 Subject: [PATCH 1/6] fix: improve edit file logic to prevent negative indentation --- src/fs_service.rs | 27 ++++++++++++++-------- src/fs_service/utils.rs | 2 +- tests/test_fs_service.rs | 50 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 11 deletions(-) diff --git a/src/fs_service.rs b/src/fs_service.rs index b5a5504..5906b37 100644 --- a/src/fs_service.rs +++ b/src/fs_service.rs @@ -480,7 +480,6 @@ impl FileSystemService { 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); @@ -488,7 +487,6 @@ impl FileSystemService { } // Otherwise, try line-by-line matching with flexibility for whitespace - // trim ends help to avoid inconsistencies empty lines at the end that may break the comparison let old_lines: Vec = normalized_old .trim_end() .split('\n') @@ -514,7 +512,6 @@ impl FileSystemService { if is_match { // Preserve original indentation of first line - // leading spaces let original_indent = content_lines[i] .chars() .take_while(|&c| c.is_whitespace()) @@ -524,12 +521,12 @@ impl FileSystemService { .split('\n') .enumerate() .map(|(j, line)| { - // keep indentation of the first line + // Keep indentation of the first line if j == 0 { return format!("{}{}", original_indent, line.trim_start()); } - // For subsequent lines, try to preserve relative indentation + // For subsequent lines, preserve relative indentation and original whitespace type let old_indent = old_lines .get(j) .map(|line| { @@ -544,12 +541,22 @@ impl FileSystemService { .take_while(|&c| c.is_whitespace()) .collect::(); - let relative_indent = new_indent.len() - old_indent.len(); - + // 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, - " ".repeat(relative_indent.max(0)) + line.trim_start() + "{}{}{}", + &original_indent, + &indent_char.repeat(relative_indent), + line.trim_start() ) }) .collect(); diff --git a/src/fs_service/utils.rs b/src/fs_service/utils.rs index 6857320..3bd11a7 100644 --- a/src/fs_service/utils.rs +++ b/src/fs_service/utils.rs @@ -103,7 +103,7 @@ pub async fn write_zip_entry( } pub fn normalize_line_endings(text: &str) -> String { - text.replace("\r\n", "\n") + text.replace("\r\n", "\n").replace('\r', "\n") } // checks if path component is a Prefix::VerbatimDisk diff --git a/tests/test_fs_service.rs b/tests/test_fs_service.rs index 016fb7f..8cb43d3 100644 --- a/tests/test_fs_service.rs +++ b/tests/test_fs_service.rs @@ -595,3 +595,53 @@ fn test_display_format_for_empty_timestamps() { assert!(display_output.contains("isDirectory: false")); assert!(display_output.contains("isFile: true")); } + +#[tokio::test] +async fn test_apply_file_edits_mixed_indentation() { + let (temp_dir, service) = setup_service(vec!["dir1".to_string()]); + let file_path = create_temp_file( + &temp_dir.join("dir1").as_path(), + "test_indent.txt", + r#" + // some descriptions + const categories = [ + { + title: 'Подготовка и исследование', + keywords: ['изуч', 'исследов', 'анализ', 'подготов', 'планиров'], + tasks: [] as any[] + }, + ]; + // some other descriptions + "#, + ); + // different indentation + let edits = vec![EditOperation { + old_text: r#"const categories = [ + { + title: 'Подготовка и исследование', + keywords: ['изуч', 'исследов', 'анализ', 'подготов', 'планиров'], + tasks: [] as any[] + }, + ];"# + .to_string(), + new_text: r#"const categories = [ + { + title: 'Подготовка и исследование', + description: 'Анализ требований и подготовка к разработке', + keywords: ['изуч', 'исследов', 'анализ', 'подготов', 'планиров'], + tasks: [] as any[] + }, + ];"# + .to_string(), + }]; + + 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())) + .await + .unwrap(); + + println!(">>> input_file {} ", file_path.display()); + println!(">>> out_file {} ", out_file.display()); +} From fc34b02bb0e2059b063e2737916a605426cbf8e2 Mon Sep 17 00:00:00 2001 From: Ali Hashemi Date: Sat, 24 May 2025 21:13:03 -0300 Subject: [PATCH 2/6] test: add more tests --- tests/test_fs_service.rs | 166 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) diff --git a/tests/test_fs_service.rs b/tests/test_fs_service.rs index 8cb43d3..2f6c7ee 100644 --- a/tests/test_fs_service.rs +++ b/tests/test_fs_service.rs @@ -645,3 +645,169 @@ async fn test_apply_file_edits_mixed_indentation() { println!(">>> input_file {} ", file_path.display()); println!(">>> out_file {} ", out_file.display()); } + +#[tokio::test] +async fn test_exact_match() { + let (temp_dir, service) = setup_service(vec!["dir1".to_string()]); + + let file = create_temp_file( + &temp_dir.as_path().join("dir1"), + "tets_file1.txt", + "hello world\n", + ); + + let edit = EditOperation { + 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) + .await + .unwrap(); + + let modified_content = fs::read_to_string(file.as_path()).unwrap(); + assert_eq!(modified_content, "hello universe\n"); + assert!(result.contains("-hello world\n+hello universe")); +} + +#[tokio::test] +async fn test_exact_match_edit2() { + let (temp_dir, service) = setup_service(vec!["dir1".to_string()]); + let file = create_temp_file( + &temp_dir.as_path().join("dir1"), + "test_file1.txt", + "hello world\n", + ); + + let edits = vec![EditOperation { + old_text: "hello world\n".into(), + new_text: "hello Rust\n".into(), + }]; + + let result = service + .apply_file_edits(&file, edits, Some(false), None) + .await; + + assert!(result.is_ok()); + let updated_content = fs::read_to_string(&file).unwrap(); + assert_eq!(updated_content, "hello Rust\n"); +} + +#[tokio::test] +async fn test_line_by_line_match_with_indent() { + let (temp_dir, service) = setup_service(vec!["dir1".to_string()]); + let file = create_temp_file( + &temp_dir.as_path().join("dir1"), + "test_file2.rs", + " let x = 42;\n println!(\"{}\");\n", + ); + + let edits = vec![EditOperation { + 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) + .await; + + assert!(result.is_ok()); + + let content = fs::read_to_string(&file).unwrap(); + assert!(content.contains("let x = 43;")); + assert!(content.contains("println!(\"x = {}\", x)")); +} + +#[tokio::test] +async fn test_dry_run_mode() { + let (temp_dir, service) = setup_service(vec!["dir1".to_string()]); + let file = create_temp_file( + &temp_dir.as_path().join("dir1"), + "test_file4.sh", + "echo hello\n", + ); + + let edits = vec![EditOperation { + old_text: "echo hello\n".into(), + new_text: "echo world\n".into(), + }]; + + let result = service + .apply_file_edits(&file, edits, Some(true), None) + .await; + assert!(result.is_ok()); + + let content = fs::read_to_string(&file).unwrap(); + assert_eq!(content, "echo hello\n"); // Should not be modified +} + +#[tokio::test] +async fn test_save_to_different_path() { + let (temp_dir, service) = setup_service(vec!["dir1".to_string()]); + let orig_file = create_temp_file( + &temp_dir.as_path().join("dir1"), + "test_file5.txt", + "foo = 1\n", + ); + + let save_to = temp_dir.as_path().join("dir1").join("saved_output.txt"); + + let edits = vec![EditOperation { + 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)) + .await; + + assert!(result.is_ok()); + + let original_content = fs::read_to_string(&orig_file).unwrap(); + let saved_content = fs::read_to_string(&save_to).unwrap(); + assert_eq!(original_content, "foo = 1\n"); + assert_eq!(saved_content, "foo = 2\n"); +} + +#[tokio::test] +async fn test_diff_backtick_formatting() { + let (temp_dir, service) = setup_service(vec!["dir1".to_string()]); + let file = create_temp_file( + &temp_dir.as_path().join("dir1"), + "test_file6.md", + "```\nhello\n```\n", + ); + + let edits = vec![EditOperation { + old_text: "```\nhello\n```".into(), + new_text: "```\nworld\n```".into(), + }]; + + let result = service + .apply_file_edits(&file, edits, Some(true), None) + .await; + assert!(result.is_ok()); + + let diff = result.unwrap(); + assert!(diff.contains("diff")); + assert!(diff.starts_with("```")); // Should start with fenced backticks +} + +#[tokio::test] +async fn test_no_edits_provided() { + let (temp_dir, service) = setup_service(vec!["dir1".to_string()]); + let file = create_temp_file( + &temp_dir.as_path().join("dir1"), + "test_file7.toml", + "enabled = true\n", + ); + + let result = service + .apply_file_edits(&file, vec![], Some(false), None) + .await; + assert!(result.is_ok()); + + let content = fs::read_to_string(&file).unwrap(); + assert_eq!(content, "enabled = true\n"); +} From 70fa5610e8bbe49d13cbf6a1eb321712470ec6e9 Mon Sep 17 00:00:00 2001 From: Ali Hashemi Date: Sat, 24 May 2025 21:15:34 -0300 Subject: [PATCH 3/6] chore: update tests --- tests/test_fs_service.rs | 62 ++++++++++++++++++++++++++++++++-------- 1 file changed, 50 insertions(+), 12 deletions(-) diff --git a/tests/test_fs_service.rs b/tests/test_fs_service.rs index 2f6c7ee..1a8111f 100644 --- a/tests/test_fs_service.rs +++ b/tests/test_fs_service.rs @@ -528,13 +528,6 @@ async fn test_write_zip_entry_non_existent_file() { assert!(result.is_err()); } -// use super::{format_permissions, format_system_time, FileInfo}; -// use std::fs::{self, File}; -// use std::io::Write; -// use std::path::Path; -// use std::time::{Duration, SystemTime}; -// use tempfile::TempDir; - #[test] fn test_file_info_for_regular_file() { let (_dir, file_info) = create_temp_file_info(b"Hello, world!"); @@ -637,13 +630,58 @@ async fn test_apply_file_edits_mixed_indentation() { let out_file = temp_dir.join("dir1").join("out_indent.txt"); - let _result = service + let result = service .apply_file_edits(&file_path, edits, Some(false), Some(&out_file.as_path())) - .await - .unwrap(); + .await; - println!(">>> input_file {} ", file_path.display()); - println!(">>> out_file {} ", out_file.display()); + assert!(result.is_ok()); +} + +#[tokio::test] +async fn test_apply_file_edits_mixed_indentation_2() { + let (temp_dir, service) = setup_service(vec!["dir1".to_string()]); + let file_path = create_temp_file( + &temp_dir.join("dir1").as_path(), + "test_indent.txt", + r#" + // some descriptions + const categories = [ + { + title: 'Подготовка и исследование', + keywords: ['изуч', 'исследов', 'анализ', 'подготов', 'планиров'], + tasks: [] as any[] + }, + ]; + // some other descriptions + "#, + ); + // different indentation + let edits = vec![EditOperation { + old_text: r#"const categories = [ + { + title: 'Подготовка и исследование', + keywords: ['изуч', 'исследов', 'анализ', 'подготов', 'планиров'], + tasks: [] as any[] + }, + ];"# + .to_string(), + new_text: r#"const categories = [ + { + title: 'Подготовка и исследование', + description: 'Анализ требований и подготовка к разработке', + keywords: ['изуч', 'исследов', 'анализ', 'подготов', 'планиров'], + tasks: [] as any[] + }, + ];"# + .to_string(), + }]; + + 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())) + .await; + assert!(result.is_ok()); } #[tokio::test] From b4cbc7e269e14ff63e1e844b5c824f22ec412245 Mon Sep 17 00:00:00 2001 From: Ali Hashemi Date: Sat, 24 May 2025 21:25:50 -0300 Subject: [PATCH 4/6] feat: preserve line ending --- src/fs_service.rs | 12 ++++++++++ tests/test_fs_service.rs | 47 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/src/fs_service.rs b/src/fs_service.rs index 5906b37..b623fa7 100644 --- a/src/fs_service.rs +++ b/src/fs_service.rs @@ -121,6 +121,16 @@ impl FileSystemService { }) } + fn detect_line_ending(&self, text: &str) -> &str { + if text.contains("\r\n") { + "\r\n" + } else if text.contains('\r') { + "\r" + } else { + "\n" + } + } + pub async fn zip_directory( &self, input_dir: String, @@ -472,6 +482,7 @@ impl FileSystemService { // Read file content and normalize line endings let content_str = tokio::fs::read_to_string(&valid_path).await?; + let original_line_ending = self.detect_line_ending(&content_str); let content_str = normalize_line_endings(&content_str); // Apply edits sequentially @@ -600,6 +611,7 @@ impl FileSystemService { if !is_dry_run { let target = save_to.unwrap_or(valid_path.as_path()); + let modified_content = modified_content.replace("\n", original_line_ending); tokio::fs::write(target, modified_content).await?; } diff --git a/tests/test_fs_service.rs b/tests/test_fs_service.rs index 1a8111f..adc2c52 100644 --- a/tests/test_fs_service.rs +++ b/tests/test_fs_service.rs @@ -849,3 +849,50 @@ async fn test_no_edits_provided() { let content = fs::read_to_string(&file).unwrap(); assert_eq!(content, "enabled = true\n"); } + +#[tokio::test] +async fn test_preserve_windows_line_endings() { + let (temp_dir, service) = setup_service(vec!["dir1".to_string()]); + let file = create_temp_file( + &temp_dir.as_path().join("dir1"), + "test_file.txt", + "line1\r\nline2\r\n", + ); + + let edits = vec![EditOperation { + old_text: "line1\nline2".into(), // normalized format + new_text: "updated1\nupdated2".into(), + }]; + + let result = service + .apply_file_edits(&file, edits, Some(false), None) + .await; + assert!(result.is_ok()); + + let output = std::fs::read_to_string(&file).unwrap(); + assert_eq!(output, "updated1\r\nupdated2\r\n"); // Line endings preserved! +} + +#[tokio::test] +async fn test_preserve_unix_line_endings() { + let (temp_dir, service) = setup_service(vec!["dir1".to_string()]); + let file = create_temp_file( + &temp_dir.as_path().join("dir1"), + "unix_line_file.txt", + "line1\nline2\n", + ); + + let edits = vec![EditOperation { + old_text: "line1\nline2".into(), + new_text: "updated1\nupdated2".into(), + }]; + + let result = service + .apply_file_edits(&file, edits, Some(false), None) + .await; + + assert!(result.is_ok()); + + let updated = std::fs::read_to_string(&file).unwrap(); + assert_eq!(updated, "updated1\nupdated2\n"); // Still uses \n endings +} From 9b2f3e0d85eb90173ba04d657b1d65a0dcc0aeee Mon Sep 17 00:00:00 2001 From: Ali Hashemi Date: Sat, 24 May 2025 21:54:19 -0300 Subject: [PATCH 5/6] cleanup --- src/fs_service.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/fs_service.rs b/src/fs_service.rs index b623fa7..d657eec 100644 --- a/src/fs_service.rs +++ b/src/fs_service.rs @@ -62,11 +62,11 @@ impl FileSystemService { let expanded_path = expand_home(requested_path.to_path_buf()); // Resolve the absolute path - let absolute_path = expanded_path - .as_path() - .is_absolute() - .then(|| expanded_path.clone()) - .unwrap_or_else(|| env::current_dir().unwrap().join(&expanded_path)); + let absolute_path = if expanded_path.as_path().is_absolute() { + expanded_path.clone() + } else { + env::current_dir().unwrap().join(&expanded_path) + }; // Normalize the path let normalized_requested = normalize_path(&absolute_path); From 43137bd476c092f1ef3af187745e8bbf337ceb28 Mon Sep 17 00:00:00 2001 From: Ali Hashemi Date: Sat, 24 May 2025 22:00:16 -0300 Subject: [PATCH 6/6] chore: fix clippy warnings --- tests/test_fs_service.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_fs_service.rs b/tests/test_fs_service.rs index adc2c52..bb3b2ea 100644 --- a/tests/test_fs_service.rs +++ b/tests/test_fs_service.rs @@ -593,7 +593,7 @@ fn test_display_format_for_empty_timestamps() { async fn test_apply_file_edits_mixed_indentation() { let (temp_dir, service) = setup_service(vec!["dir1".to_string()]); let file_path = create_temp_file( - &temp_dir.join("dir1").as_path(), + temp_dir.join("dir1").as_path(), "test_indent.txt", r#" // some descriptions @@ -631,7 +631,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())) .await; assert!(result.is_ok()); @@ -641,7 +641,7 @@ async fn test_apply_file_edits_mixed_indentation() { async fn test_apply_file_edits_mixed_indentation_2() { let (temp_dir, service) = setup_service(vec!["dir1".to_string()]); let file_path = create_temp_file( - &temp_dir.join("dir1").as_path(), + temp_dir.join("dir1").as_path(), "test_indent.txt", r#" // some descriptions @@ -679,7 +679,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())) .await; assert!(result.is_ok()); }