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