1use serde::{Deserialize, Serialize};
2
3use crate::fuzzy::{
4 FuzzyCandidate, diagnostic_candidates, normalized_unique_match_range,
5 strip_line_number_prefixes,
6};
7use crate::hunks::{EditHunk, text_edit_hunk};
8use crate::{EditToolResult, TextEdit};
9
10#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
11#[serde(rename_all = "snake_case")]
12pub enum EditMatchMode {
13 Off,
14 Diagnose,
15 ApplySafe,
16}
17
18#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
19pub struct EditOptions {
20 pub fuzzy: EditMatchMode,
21 pub strip_line_numbers: bool,
22 #[serde(default)]
29 pub reindent_inserted: bool,
30}
31
32impl Default for EditOptions {
33 fn default() -> Self {
34 Self {
35 fuzzy: EditMatchMode::Diagnose,
36 strip_line_numbers: true,
37 reindent_inserted: false,
38 }
39 }
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
43pub enum EditApplyError {
44 OldStringNotFound {
45 edit: Option<usize>,
46 candidates: Vec<FuzzyCandidate>,
47 },
48 OldStringAmbiguous {
49 edit: Option<usize>,
50 occurrences: usize,
51 candidates: Vec<FuzzyCandidate>,
52 },
53}
54
55pub fn apply_edit(
56 path: impl Into<String>,
57 text: &str,
58 old_string: &str,
59 new_string: &str,
60 options: EditOptions,
61) -> Result<(String, EditToolResult), EditApplyError> {
62 let edit = TextEdit {
63 old_string: old_string.to_string(),
64 new_string: new_string.to_string(),
65 };
66 apply_multi_edit(path, text, &[edit], options)
67}
68
69pub fn apply_multi_edit(
70 path: impl Into<String>,
71 text: &str,
72 edits: &[TextEdit],
73 options: EditOptions,
74) -> Result<(String, EditToolResult), EditApplyError> {
75 let path = path.into();
76 let mut updated = text.to_string();
77 let mut hunks = Vec::new();
78 for (index, edit) in edits.iter().enumerate() {
79 let old_string = if options.strip_line_numbers {
80 strip_line_number_prefixes(&edit.old_string)
81 } else {
82 edit.old_string.clone()
83 };
84 let matches = match_positions(&updated, &old_string);
85 let range = match matches.as_slice() {
86 [position] => *position..*position + old_string.len(),
87 [] => match options.fuzzy {
88 EditMatchMode::Off | EditMatchMode::Diagnose => {
89 return Err(EditApplyError::OldStringNotFound {
90 edit: Some(index),
91 candidates: diagnostic_candidates(&updated, &old_string, 3),
92 });
93 }
94 EditMatchMode::ApplySafe => normalized_unique_match_range(&updated, &old_string)
95 .ok_or_else(|| EditApplyError::OldStringNotFound {
96 edit: Some(index),
97 candidates: diagnostic_candidates(&updated, &old_string, 3),
98 })?,
99 },
100 _ => {
101 return Err(EditApplyError::OldStringAmbiguous {
102 edit: Some(index),
103 occurrences: matches.len(),
104 candidates: diagnostic_candidates(&updated, &old_string, 3),
105 });
106 }
107 };
108 let matched_old = updated[range.clone()].to_string();
111 let new_string = if options.reindent_inserted {
112 crate::post_edit::normalize_inserted_indentation(&matched_old, &edit.new_string)
113 } else {
114 edit.new_string.clone()
115 };
116 updated.replace_range(range, &new_string);
117 hunks.push(text_edit_hunk(&path, &matched_old, &new_string, index));
118 }
119 Ok((
120 updated,
121 EditToolResult {
122 path,
123 replacements: edits.len(),
124 hunks,
125 },
126 ))
127}
128
129fn match_positions(haystack: &str, needle: &str) -> Vec<usize> {
130 if needle.is_empty() {
131 return Vec::new();
132 }
133 haystack
134 .match_indices(needle)
135 .map(|(index, _)| index)
136 .collect()
137}
138
139pub fn hunks_for_edits(path: impl Into<String>, edits: &[TextEdit]) -> Vec<EditHunk> {
140 let path = path.into();
141 edits
142 .iter()
143 .enumerate()
144 .map(|(index, edit)| text_edit_hunk(&path, &edit.old_string, &edit.new_string, index))
145 .collect()
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151
152 #[test]
153 fn applies_exact_edit_once() {
154 let (updated, outcome) = apply_edit(
155 "src/lib.rs",
156 "fn main() { true }",
157 "true",
158 "false",
159 EditOptions::default(),
160 )
161 .unwrap();
162 assert_eq!(updated, "fn main() { false }");
163 assert_eq!(outcome.replacements, 1);
164 assert_eq!(outcome.hunks.len(), 1);
165 }
166
167 #[test]
168 fn refuses_ambiguous_edit() {
169 let err = apply_edit("x", "foo foo", "foo", "bar", EditOptions::default()).unwrap_err();
170 assert!(matches!(
171 err,
172 EditApplyError::OldStringAmbiguous { occurrences: 2, .. }
173 ));
174 }
175
176 #[test]
177 fn strips_line_numbers_before_matching() {
178 let (updated, _) = apply_edit(
179 "x",
180 "foo\nbar",
181 "1: foo\n2: bar",
182 "baz",
183 EditOptions::default(),
184 )
185 .unwrap();
186 assert_eq!(updated, "baz");
187 }
188}