Skip to main content

scope_engine/
patch.rs

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            // New file creation: only line-1 operations are valid (no
315            // existing content to anchor against).  Replace at line 1 is
316            // accepted and treated equivalently to Append.
317            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            // Resolve op: default to Append for new files, required for existing
336            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            // Resolve start anchor: default to "1#" for new files, required for existing
349            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        // Line 1: "pub fn keep() {" -> hashes[0]
604        // Line 5: "pub fn remove_me() {" -> hashes[4]
605        // Line 7: "}" -> hashes[6]
606
607        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, // delete
626            },
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()), // wrong hash
685            end: Some("3#ff".to_string()),   // wrong hash
686            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, // missing end
751            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}