1use std::collections::{HashMap, HashSet};
2use std::path::{Path, PathBuf};
3
4use crate::analyzer::Analyzer;
5pub use crate::api::{
6 AppliedStructuredEditFile, AppliedStructuredEditOperation, AppliedStructuredEditSummary,
7};
8use crate::api::{EditOp, PropagationResult, PropagationSource, StructuredEdit};
9use crate::treesitter::TreeSitterAnalyzer;
10use sha2::{Digest, Sha256};
11use std::sync::Mutex;
12
13#[derive(Debug, Clone)]
14struct PlannedEdit {
15 start_line: usize,
16 old_count: usize,
17 replacement: Vec<String>,
18 primary_symbol_name: Option<String>,
19}
20
21struct PreparedFileEdits {
22 display_path: String,
23 full_path: PathBuf,
24 existed: bool,
25 original: String,
26 new_content: String,
27 planned: Vec<PlannedEdit>,
28}
29
30struct PreparedStructuredEdits {
31 files: Vec<PreparedFileEdits>,
32}
33
34pub fn line_hash(line_content: &str) -> String {
35 let mut hasher = Sha256::new();
36 hasher.update(line_content.as_bytes());
37 let result = hasher.finalize();
38 format!("{:02x}", result[0])
39}
40
41fn parse_start_anchor(anchor: &str) -> Result<(usize, String), String> {
42 let (line_str, hash_str) = anchor
43 .split_once('#')
44 .ok_or_else(|| format!("invalid start anchor (expected line#hash): {anchor}"))?;
45 let line = line_str
46 .parse::<usize>()
47 .map_err(|_| format!("invalid line number in anchor: {anchor}"))?;
48 if line == 0 {
49 return Err(format!("line number must be >= 1 in anchor: {anchor}"));
50 }
51 Ok((line, hash_str.to_string()))
52}
53
54fn verify_line(content: &str, line_num: usize, expected_hash: &str) -> Result<(), String> {
55 let lines: Vec<&str> = content.lines().collect();
56 if line_num > lines.len() {
57 return Err(format!(
58 "line {line_num} out of bounds (file has {} lines)",
59 lines.len()
60 ));
61 }
62 let actual = lines[line_num - 1];
63 let actual_hash = line_hash(actual);
64 if actual_hash != expected_hash {
65 return Err(format!(
66 "line {line_num} hash mismatch: expected {expected_hash}, got {actual_hash} — file may have changed; re-read before editing"
67 ));
68 }
69 Ok(())
70}
71
72fn apply_planned_edits_to_content(
73 original: &str,
74 edits: &[PlannedEdit],
75 file_display: &str,
76) -> Result<String, String> {
77 let mut sorted: Vec<&PlannedEdit> = edits.iter().collect();
78 sorted.sort_by_key(|edit| edit.start_line);
79 for pair in sorted.windows(2) {
80 let prev_end = pair[0].start_line + pair[0].old_count;
81 if pair[1].start_line < prev_end {
82 return Err(format!(
83 "overlapping edits in {}: edit at line {} overlaps previous edit ending at line {}",
84 file_display, pair[1].start_line, prev_end
85 ));
86 }
87 }
88
89 let mut lines = original.lines().map(str::to_string).collect::<Vec<_>>();
90 for edit in sorted.iter().rev() {
91 let start_idx = edit.start_line.saturating_sub(1);
92 let end_idx = start_idx + edit.old_count;
93 if start_idx > lines.len() || end_idx > lines.len() {
94 return Err(format!(
95 "edit exceeds file bounds in {}: lines {}-{} but file has {} lines",
96 file_display,
97 edit.start_line,
98 edit.start_line + edit.old_count,
99 lines.len()
100 ));
101 }
102 lines.splice(start_idx..end_idx, edit.replacement.clone());
103 }
104
105 if lines.is_empty() {
106 Ok(String::new())
107 } else {
108 Ok(lines.join("\n") + "\n")
109 }
110}
111
112struct PropagationCollectionContext<'a> {
113 full_path: &'a Path,
114 original: &'a str,
115 new_content: &'a str,
116 project_root: &'a Path,
117 lsp_analyzer: &'a Mutex<Option<Box<dyn Analyzer + Send>>>,
118 analyzer: &'a TreeSitterAnalyzer,
119}
120
121fn collect_propagation_results(
122 context: PropagationCollectionContext<'_>,
123 edits: &[PlannedEdit],
124) -> Vec<PropagationResult> {
125 let PropagationCollectionContext {
126 full_path,
127 original,
128 new_content,
129 project_root,
130 lsp_analyzer,
131 analyzer,
132 } = context;
133
134 let mut results: Vec<PropagationResult> = Vec::new();
135 let mut seen: HashSet<String> = HashSet::new();
136 let mut modified_symbol_names = HashSet::new();
137
138 for edit in edits {
139 if let Some(ref name) = edit.primary_symbol_name {
140 modified_symbol_names.insert(name.clone());
141 }
142 if let Some(sel) = analyzer.find_containing_symbol(full_path, edit.start_line, project_root)
143 && let Ok(parsed) = crate::selector::parse_selector(&sel)
144 && let Some(name) = parsed.name()
145 {
146 modified_symbol_names.insert(name.to_string());
147 }
148 }
149
150 for sym_name in &modified_symbol_names {
151 let mut lsp_refs: Vec<PropagationResult> = Vec::new();
152 if let Ok(lsp_guard) = lsp_analyzer.lock()
153 && let Some(ref lsp) = *lsp_guard
154 {
155 let (line, character) =
156 find_symbol_position(new_content, sym_name).unwrap_or_else(|| {
157 let hint_line = edits.first().map(|edit| edit.start_line).unwrap_or(1);
158 (hint_line, 0)
159 });
160 lsp_refs = lsp.find_references_for_symbol(full_path, line, character, project_root);
161 }
162 if lsp_refs.is_empty() {
163 let rel = normalize_for_comparison(full_path)
164 .strip_prefix(normalize_for_comparison(project_root))
165 .ok()
166 .map(|p| p.to_string_lossy().to_string())
167 .unwrap_or_else(|| full_path.to_string_lossy().to_string());
168 let selector = format!("{}::{}", rel, sym_name);
169 if seen.insert(selector.clone()) {
170 let first_line = edits.first().map(|edit| edit.start_line).unwrap_or(1);
171 let file_snippet = original
172 .lines()
173 .skip(first_line.saturating_sub(3))
174 .take(7)
175 .collect::<Vec<_>>()
176 .join("\n");
177 let project_files = std::fs::read_dir(project_root)
178 .ok()
179 .map(|entries| {
180 entries
181 .filter_map(|e| e.ok())
182 .filter(|e| {
183 e.path().is_dir()
184 && e.path().file_name().is_some_and(|n| n == "src")
185 })
186 .filter_map(|e| std::fs::read_dir(e.path()).ok())
187 .flat_map(|entries| {
188 let normalized_root = normalize_for_comparison(project_root);
189 entries.filter_map(|e| e.ok()).filter_map(move |e| {
190 normalize_for_comparison(&e.path())
191 .strip_prefix(&normalized_root)
192 .ok()
193 .map(|p| p.to_string_lossy().to_string())
194 })
195 })
196 .collect()
197 })
198 .unwrap_or_default();
199
200 results.push(PropagationResult {
201 selector,
202 reason: format!(
203 "symbol \"{}\" was modified; no LSP available to find references",
204 sym_name
205 ),
206 source: PropagationSource::OpenEnded,
207 lsp_references: None,
208 diff_summary: Some("hash-based edit".to_string()),
209 file_snippet: Some(file_snippet),
210 project_files: Some(project_files),
211 });
212 }
213 } else {
214 for r in lsp_refs {
215 if seen.insert(r.selector.clone()) {
216 results.push(r);
217 }
218 }
219 }
220 }
221
222 results
223}
224
225fn find_symbol_position(content: &str, symbol_name: &str) -> Option<(usize, usize)> {
226 content.lines().enumerate().find_map(|(line_idx, line)| {
227 line.find(symbol_name)
228 .map(|character| (line_idx + 1, character))
229 })
230}
231
232fn normalized_edit_content(content: Option<&crate::api::EditContent>) -> Option<Vec<String>> {
233 content.map(|c| c.clone().into_lines())
234}
235
236pub fn edit_code_apply(
237 edits: &[StructuredEdit],
238 project_root: &Path,
239 lsp_analyzer: &Mutex<Option<Box<dyn Analyzer + Send>>>,
240) -> Result<(Vec<PropagationResult>, AppliedStructuredEditSummary), String> {
241 let analyzer = TreeSitterAnalyzer::new();
242 let prepared = prepare_structured_edits(edits, project_root, &analyzer, true)?;
243 let applied_summary = applied_summary_from_prepared(&prepared);
244 write_prepared_structured_edits(&prepared, Some(lsp_analyzer))?;
245
246 let mut results = Vec::new();
247 for file in &prepared.files {
248 results.extend(collect_propagation_results(
249 PropagationCollectionContext {
250 full_path: &file.full_path,
251 original: &file.original,
252 new_content: &file.new_content,
253 project_root,
254 lsp_analyzer,
255 analyzer: &analyzer,
256 },
257 &file.planned,
258 ));
259 }
260
261 Ok((results, applied_summary))
262}
263
264pub fn edit_file_apply(
265 edits: &[StructuredEdit],
266 project_root: &Path,
267) -> Result<AppliedStructuredEditSummary, String> {
268 let analyzer = TreeSitterAnalyzer::new();
269 let prepared = prepare_structured_edits(edits, project_root, &analyzer, false)?;
270 write_prepared_structured_edits(&prepared, None)?;
271 Ok(applied_summary_from_prepared(&prepared))
272}
273
274fn prepare_structured_edits(
275 edits: &[StructuredEdit],
276 project_root: &Path,
277 analyzer: &TreeSitterAnalyzer,
278 validate_parse: bool,
279) -> Result<PreparedStructuredEdits, String> {
280 if edits.is_empty() {
281 return Err("edits array is empty".to_string());
282 }
283
284 struct EditGroup<'a> {
285 display_path: String,
286 edits: Vec<&'a StructuredEdit>,
287 }
288
289 let mut edits_by_file: HashMap<PathBuf, EditGroup<'_>> = HashMap::new();
290 for edit in edits {
291 let full_path = if std::path::Path::new(&edit.path).is_absolute() {
292 PathBuf::from(&edit.path)
293 } else {
294 project_root.join(&edit.path)
295 };
296 let display_path = display_path_for_edit(project_root, &full_path);
297 edits_by_file
298 .entry(full_path)
299 .and_modify(|group| group.edits.push(edit))
300 .or_insert_with(|| EditGroup {
301 display_path,
302 edits: vec![edit],
303 });
304 }
305
306 let mut prepared_files = Vec::new();
307
308 for (full_path, group) in edits_by_file {
309 let existed = full_path.exists();
310 let original = if existed {
311 std::fs::read_to_string(&full_path)
312 .map_err(|e| format!("cannot read {}: {e}", full_path.display()))?
313 } else {
314 for e in &group.edits {
318 let start_valid = match &e.start {
319 Some(start) => start == "1#" || start.starts_with("1#"),
320 None => true,
321 };
322 if !start_valid {
323 return Err(format!(
324 "cannot create new file {}: start anchor must be `1#`",
325 full_path.display()
326 ));
327 }
328 }
329 String::new()
330 };
331
332 let mut planned: Vec<PlannedEdit> = Vec::new();
333
334 for edit in group.edits {
335 let op = match edit.op.clone() {
337 Some(op) if original.is_empty() && op == EditOp::Replace => EditOp::Append,
338 Some(op) => op,
339 None if original.is_empty() => EditOp::Append,
340 None => {
341 return Err(format!(
342 "op is required for editing existing file {}",
343 edit.path
344 ));
345 }
346 };
347
348 let start = match &edit.start {
350 Some(s) => s.clone(),
351 None if original.is_empty() => "1#".to_string(),
352 None => {
353 return Err(format!(
354 "start anchor is required for editing existing file {}",
355 edit.path
356 ));
357 }
358 };
359
360 let (start_line, start_hash) = parse_start_anchor(&start)?;
361
362 if !original.is_empty() {
363 verify_line(&original, start_line, &start_hash)?;
364 }
365
366 let mut primary_symbol_name = None;
367 if validate_parse
368 && !original.is_empty()
369 && let Some(sel) =
370 analyzer.find_containing_symbol(&full_path, start_line, project_root)
371 && let Ok(parsed) = crate::selector::parse_selector(&sel)
372 {
373 primary_symbol_name = parsed.name().map(str::to_string);
374 }
375
376 match op {
377 EditOp::Replace => {
378 let (end_line, end_hash) = match &edit.end {
379 Some(end_anchor) => parse_start_anchor(end_anchor)?,
380 None => {
381 return Err(format!("replace requires `end` anchor for {}", edit.path));
382 }
383 };
384 if end_line < start_line {
385 return Err(format!(
386 "replace end line {} is before start line {} in {}",
387 end_line, start_line, edit.path
388 ));
389 }
390 if !original.is_empty() {
391 verify_line(&original, end_line, &end_hash)?;
392 }
393 let old_count = end_line - start_line + 1;
394 let replacement =
395 normalized_edit_content(edit.content.as_ref()).unwrap_or_default();
396 planned.push(PlannedEdit {
397 start_line,
398 old_count,
399 replacement,
400 primary_symbol_name,
401 });
402 }
403 EditOp::Append => {
404 let replacement =
405 normalized_edit_content(edit.content.as_ref()).unwrap_or_default();
406 let insert_line = if original.is_empty() {
407 1
408 } else {
409 start_line + 1
410 };
411 planned.push(PlannedEdit {
412 start_line: insert_line,
413 old_count: 0,
414 replacement,
415 primary_symbol_name,
416 });
417 }
418 EditOp::Prepend => {
419 let replacement =
420 normalized_edit_content(edit.content.as_ref()).unwrap_or_default();
421 let insert_line = if original.is_empty() { 1 } else { start_line };
422 planned.push(PlannedEdit {
423 start_line: insert_line,
424 old_count: 0,
425 replacement,
426 primary_symbol_name,
427 });
428 }
429 }
430 }
431
432 let new_content =
433 apply_planned_edits_to_content(&original, &planned, &full_path.to_string_lossy())?;
434
435 let ext = full_path.extension().and_then(|e| e.to_str()).unwrap_or("");
436 if validate_parse
437 && !ext.is_empty()
438 && !new_content.is_empty()
439 && let Some(diagnostic) = analyzer.parse_error_diagnostic(ext, &new_content)
440 {
441 return Err(format!(
442 "edit rejected: tree-sitter cannot parse the result for {}\n{}",
443 full_path.display(),
444 diagnostic.message()
445 ));
446 }
447
448 prepared_files.push(PreparedFileEdits {
449 display_path: group.display_path,
450 full_path,
451 existed,
452 original,
453 new_content,
454 planned,
455 });
456 }
457
458 Ok(PreparedStructuredEdits {
459 files: prepared_files,
460 })
461}
462
463fn write_prepared_structured_edits(
464 prepared: &PreparedStructuredEdits,
465 lsp_analyzer: Option<&Mutex<Option<Box<dyn Analyzer + Send>>>>,
466) -> Result<(), String> {
467 for file in &prepared.files {
468 if let Some(parent) = file.full_path.parent() {
469 std::fs::create_dir_all(parent).map_err(|e| {
470 format!(
471 "cannot create parent dirs for {}: {e}",
472 file.full_path.display()
473 )
474 })?;
475 }
476 std::fs::write(&file.full_path, &file.new_content)
477 .map_err(|e| format!("cannot write {}: {e}", file.full_path.display()))?;
478 if let Some(lsp_analyzer) = lsp_analyzer
479 && let Ok(lsp_guard) = lsp_analyzer.lock()
480 && let Some(ref lsp) = *lsp_guard
481 {
482 lsp.notify_did_change(&file.full_path, 1, &file.new_content);
483 }
484 }
485 Ok(())
486}
487
488fn applied_summary_from_prepared(
489 prepared: &PreparedStructuredEdits,
490) -> AppliedStructuredEditSummary {
491 let files = prepared
492 .files
493 .iter()
494 .map(|file| {
495 let added_lines = file.planned.iter().map(|edit| edit.replacement.len()).sum();
496 let removed_lines = file.planned.iter().map(|edit| edit.old_count).sum();
497 AppliedStructuredEditFile {
498 path: file.display_path.clone(),
499 operation: if file.existed {
500 AppliedStructuredEditOperation::Update
501 } else {
502 AppliedStructuredEditOperation::Add
503 },
504 added_lines,
505 removed_lines,
506 original_content: file.original.clone(),
507 new_content: file.new_content.clone(),
508 }
509 })
510 .collect();
511 AppliedStructuredEditSummary { files }
512}
513
514fn display_path_for_edit(project_root: &Path, full_path: &Path) -> String {
515 let normalized_root = normalize_for_comparison(project_root);
516 let normalized_path = normalize_for_comparison(full_path);
517 if let Ok(relative) = normalized_path.strip_prefix(&normalized_root) {
518 let relative = relative.to_string_lossy().replace('\\', "/");
519 if !relative.is_empty() {
520 return relative;
521 }
522 }
523 full_path.to_string_lossy().replace('\\', "/")
524}
525
526fn normalize_for_comparison(p: &Path) -> PathBuf {
527 let s = p.to_string_lossy();
528 if let Some(rest) = s.strip_prefix(r"\\?\") {
529 PathBuf::from(rest)
530 } else {
531 p.to_path_buf()
532 }
533}
534
535#[cfg(test)]
536mod e2e_tests {
537 use super::*;
538 use crate::api;
539 use std::io::Write;
540 use std::path::PathBuf;
541
542 fn setup_temp_rust_project() -> tempfile::TempDir {
543 tempfile::tempdir().unwrap()
544 }
545
546 fn write_rust_file(dir: &Path, filename: &str, content: &str) -> (PathBuf, Vec<String>) {
547 let src_dir = dir.join("src");
548 std::fs::create_dir_all(&src_dir).unwrap();
549 let path = src_dir.join(filename);
550 let mut f = std::fs::File::create(&path).unwrap();
551 f.write_all(content.as_bytes()).unwrap();
552
553 let hashes: Vec<String> = content
554 .lines()
555 .enumerate()
556 .map(|(i, line)| format!("{}#{}", i + 1, line_hash(line)))
557 .collect();
558
559 (path, hashes)
560 }
561
562 #[test]
563 fn replace_with_hashes() {
564 let dir = setup_temp_rust_project();
565 let rust_code = "pub fn hello() {\n println!(\"hello\");\n}\n\npub fn world() {\n println!(\"world\");\n}\n";
566 let (_, hashes) = write_rust_file(dir.path(), "lib.rs", rust_code);
567
568 let edits = vec![api::StructuredEdit {
569 path: "src/lib.rs".to_string(),
570 op: Some(api::EditOp::Replace),
571 start: Some(hashes[0].clone()),
572 end: Some(hashes[2].clone()),
573 content: Some(api::EditContent::Lines(vec![
574 "pub fn hello() {".to_string(),
575 " println!(\"hello world\");".to_string(),
576 "}".to_string(),
577 ])),
578 }];
579 let lsp: Mutex<Option<Box<dyn Analyzer + Send>>> = Mutex::new(None);
580 let (propagation, applied_summary) = edit_code_apply(&edits, dir.path(), &lsp).unwrap();
581 assert!(!propagation.is_empty(), "Should have propagation results");
582 assert_eq!(applied_summary.files.len(), 1);
583 assert_eq!(applied_summary.files[0].path, "src/lib.rs");
584 assert_eq!(applied_summary.files[0].added_lines, 3);
585 assert_eq!(applied_summary.files[0].removed_lines, 3);
586 assert!(
587 applied_summary.files[0]
588 .original_content
589 .contains("\"hello\"")
590 );
591 assert!(applied_summary.files[0].new_content.contains("hello world"));
592
593 let modified = std::fs::read_to_string(dir.path().join("src/lib.rs")).unwrap();
594 assert!(modified.contains("hello world"));
595 assert!(!modified.contains("\"hello\""));
596 }
597
598 #[test]
599 fn append_and_replace_delete() {
600 let dir = setup_temp_rust_project();
601 let rust_code = "pub fn keep() {\n println!(\"keep\");\n}\n\npub fn remove_me() {\n println!(\"remove\");\n}\n";
602 let (_, hashes) = write_rust_file(dir.path(), "lib.rs", rust_code);
603 let edits = vec![
608 api::StructuredEdit {
609 path: "src/lib.rs".to_string(),
610 op: Some(api::EditOp::Append),
611 start: Some(hashes[0].clone()),
612 end: None,
613 content: Some(api::EditContent::Lines(vec![
614 "pub fn added() {".to_string(),
615 " println!(\"added\");".to_string(),
616 "}".to_string(),
617 "".to_string(),
618 ])),
619 },
620 api::StructuredEdit {
621 path: "src/lib.rs".to_string(),
622 op: Some(api::EditOp::Replace),
623 start: Some(hashes[4].clone()),
624 end: Some(hashes[6].clone()),
625 content: None, },
627 ];
628 let lsp: Mutex<Option<Box<dyn Analyzer + Send>>> = Mutex::new(None);
629 let (_propagation, applied_summary) = edit_code_apply(&edits, dir.path(), &lsp).unwrap();
630 assert_eq!(applied_summary.files.len(), 1);
631 assert_eq!(applied_summary.files[0].added_lines, 4);
632 assert_eq!(applied_summary.files[0].removed_lines, 3);
633
634 let modified = std::fs::read_to_string(dir.path().join("src/lib.rs")).unwrap();
635 assert!(modified.contains("pub fn added()"));
636 assert!(modified.contains("pub fn keep()"));
637 assert!(!modified.contains("remove_me"));
638 }
639
640 #[test]
641 fn creates_new_file() {
642 let dir = setup_temp_rust_project();
643 std::fs::create_dir_all(dir.path().join("src")).unwrap();
644
645 let edits = vec![api::StructuredEdit {
646 path: "src/new_file.rs".to_string(),
647 op: Some(api::EditOp::Append),
648 start: Some("1#00".to_string()),
649 end: None,
650 content: Some(api::EditContent::Lines(vec![
651 "pub fn created() {".to_string(),
652 " println!(\"created\");".to_string(),
653 "}".to_string(),
654 ])),
655 }];
656 let lsp: Mutex<Option<Box<dyn Analyzer + Send>>> = Mutex::new(None);
657 let result = edit_code_apply(&edits, dir.path(), &lsp);
658 assert!(
659 result.is_ok(),
660 "new file creation should succeed: {result:?}"
661 );
662 let (_propagation, applied_summary) = result.unwrap();
663 assert_eq!(applied_summary.files.len(), 1);
664 assert_eq!(
665 applied_summary.files[0].operation,
666 AppliedStructuredEditOperation::Add
667 );
668 assert_eq!(applied_summary.files[0].added_lines, 3);
669 assert_eq!(applied_summary.files[0].removed_lines, 0);
670
671 let created = std::fs::read_to_string(dir.path().join("src/new_file.rs")).unwrap();
672 assert!(created.contains("pub fn created()"));
673 }
674
675 #[test]
676 fn hash_mismatch_rejects_edit() {
677 let dir = setup_temp_rust_project();
678 let rust_code = "pub fn hello() {\n println!(\"hello\");\n}\n";
679 write_rust_file(dir.path(), "lib.rs", rust_code);
680
681 let edits = vec![api::StructuredEdit {
682 path: "src/lib.rs".to_string(),
683 op: Some(api::EditOp::Replace),
684 start: Some("1#ff".to_string()), end: Some("3#ff".to_string()), content: Some(api::EditContent::Text(
687 "pub fn hello() {\n println!(\"goodbye\");\n}\n".to_string(),
688 )),
689 }];
690 let lsp: Mutex<Option<Box<dyn Analyzer + Send>>> = Mutex::new(None);
691 let err = edit_code_apply(&edits, dir.path(), &lsp).unwrap_err();
692 assert!(
693 err.contains("hash mismatch"),
694 "expected hash mismatch, got: {err}"
695 );
696
697 let unchanged = std::fs::read_to_string(dir.path().join("src/lib.rs")).unwrap();
698 assert_eq!(unchanged, rust_code);
699 }
700
701 #[test]
702 fn parse_rejection_reports_tree_sitter_error_location() {
703 let dir = setup_temp_rust_project();
704 let rust_code = "pub fn hello() {\n println!(\"hello\");\n}\n";
705 let (_, hashes) = write_rust_file(dir.path(), "lib.rs", rust_code);
706
707 let edits = vec![api::StructuredEdit {
708 path: "src/lib.rs".to_string(),
709 op: Some(api::EditOp::Replace),
710 start: Some(hashes[0].clone()),
711 end: Some(hashes[2].clone()),
712 content: Some(api::EditContent::Lines(vec![
713 "pub fn hello() {".to_string(),
714 " println!(\"hello\");".to_string(),
715 ])),
716 }];
717 let lsp: Mutex<Option<Box<dyn Analyzer + Send>>> = Mutex::new(None);
718 let err = edit_code_apply(&edits, dir.path(), &lsp).unwrap_err();
719
720 assert!(err.contains("tree-sitter cannot parse the result"));
721 assert!(err.contains("src\\lib.rs") || err.contains("src/lib.rs"));
722 assert!(err.contains("first parse error:"));
723 assert!(err.contains("L"));
724 assert!(err.contains("C"));
725 assert!(err.contains("println!"));
726 assert!(err.contains('^'));
727
728 let unchanged = std::fs::read_to_string(dir.path().join("src/lib.rs")).unwrap();
729 assert_eq!(unchanged, rust_code);
730 }
731
732 #[test]
733 fn rejects_empty_edits() {
734 let dir = setup_temp_rust_project();
735 let lsp: Mutex<Option<Box<dyn Analyzer + Send>>> = Mutex::new(None);
736 let err = edit_code_apply(&[], dir.path(), &lsp).unwrap_err();
737 assert!(err.contains("empty"));
738 }
739
740 #[test]
741 fn replace_requires_end() {
742 let dir = setup_temp_rust_project();
743 let rust_code = "pub fn hello() {\n println!(\"hello\");\n}\n";
744 let (_, hashes) = write_rust_file(dir.path(), "lib.rs", rust_code);
745
746 let edits = vec![api::StructuredEdit {
747 path: "src/lib.rs".to_string(),
748 op: Some(api::EditOp::Replace),
749 start: Some(hashes[0].clone()),
750 end: None, content: Some(api::EditContent::Text("replaced".to_string())),
752 }];
753 let lsp: Mutex<Option<Box<dyn Analyzer + Send>>> = Mutex::new(None);
754 let err = edit_code_apply(&edits, dir.path(), &lsp).unwrap_err();
755 assert!(err.contains("requires `end`"));
756 }
757}