1use rusqlite::Connection;
2use serde::{Deserialize, Serialize};
3use sha2::{Digest, Sha256};
4use std::fs;
5use std::io::Write;
6use std::path::Path;
7use thiserror::Error;
8
9use crate::queries;
10use crate::slicer;
11use crate::sync;
12use crate::syntax::{self, SyntaxError};
13use crate::workspace::Workspace;
14
15#[derive(Debug, Error)]
16pub enum EditError {
17 #[error("Symbol '{0}' not found in '{1}'")]
18 SymbolNotFound(String, String),
19 #[error("Symbol has no body defined (e.g. trait declaration without default implementation)")]
20 NoBodyDefined,
21 #[error("Optimistic lock failed: expected body hash '{0}', found '{1}'")]
22 HashMismatch(String, String),
23 #[error(
24 "File offsets out of bounds: range [{0}..{1}], but file length is {2} bytes (file may have shrunk or changed)"
25 )]
26 InvalidOffsetRange(usize, usize, usize),
27 #[error("Pre-flight syntax validation failed: {0}")]
28 Syntax(#[from] SyntaxError),
29 #[error("Replacement content is not valid UTF-8: {0}")]
30 InvalidUtf8(String),
31 #[error("Failed to read/write file '{0}': {1}")]
32 Io(String, #[source] std::io::Error),
33 #[error("Synchronization failed and file was rolled back: {0}")]
34 SyncWithRollback(String),
35 #[error(
36 "Synchronization failed after edit ({sync_error}) and rollback also failed: {rollback_error}"
37 )]
38 SyncRollbackFailed {
39 sync_error: String,
40 rollback_error: String,
41 },
42 #[error("File '{0}' was concurrently modified; edit aborted")]
43 ConcurrentModification(String),
44 #[error("Synchronization failed after edit: {0}")]
45 Sync(#[from] sync::SyncError),
46 #[error("Workspace path error: {0}")]
47 Workspace(#[from] crate::workspace::WorkspaceError),
48 #[error("Query error: {0}")]
49 Query(#[from] queries::QueryError),
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct EditResult {
55 pub symbol_name: String,
56 pub file_path: String,
57 pub old_body_hash: String,
58 pub new_body_hash: String,
59 pub bytes_written: usize,
60 pub syntax_checked: bool,
61}
62
63pub fn hash_content(content: &str) -> String {
65 let mut hasher = Sha256::new();
66 hasher.update(content.as_bytes());
67 hex::encode(hasher.finalize())
68}
69
70fn is_transient_lock_error(err: &std::io::Error) -> bool {
71 #[cfg(windows)]
72 {
73 if let Some(code) = err.raw_os_error() {
74 if code == 5 || code == 32 || code == 33 {
76 return true;
77 }
78 }
79 }
80 matches!(err.kind(), std::io::ErrorKind::PermissionDenied)
81}
82
83fn persist_with_retry(
84 mut temp_file: tempfile::NamedTempFile,
85 dest: &Path,
86 expected_dest_bytes: Option<&[u8]>,
87) -> Result<(), std::io::Error> {
88 for attempt in 0..5 {
89 if attempt > 0
90 && let Some(expected) = expected_dest_bytes
91 && let Ok(current) = fs::read(dest)
92 && current != expected
93 {
94 return Err(std::io::Error::other(
95 "Destination file was concurrently modified during retry",
96 ));
97 }
98 match temp_file.persist(dest) {
99 Ok(_) => return Ok(()),
100 Err(e) => {
101 let is_transient = is_transient_lock_error(&e.error);
102 temp_file = e.file;
103 if is_transient && attempt < 4 {
104 std::thread::sleep(std::time::Duration::from_millis(10 * (1 << attempt)));
105 continue;
106 }
107 return Err(e.error);
108 }
109 }
110 }
111 unreachable!()
112}
113
114pub fn replace_symbol_body(
116 workspace: &Workspace,
117 db_path: &Path,
118 conn: &Connection,
119 symbol_name: &str,
120 file_path: &str,
121 new_body: &str,
122 expected_body_hash: Option<&str>,
123) -> Result<EditResult, EditError> {
124 let (abs_path, rel_path) = workspace.resolve_path(Path::new(file_path))?;
125
126 sync::ensure_fresh_file(workspace, db_path, conn, &rel_path)?;
128
129 let symbol = queries::get_symbol_by_name_exact(conn, symbol_name, &rel_path)?
131 .ok_or_else(|| EditError::SymbolNotFound(symbol_name.to_string(), rel_path.clone()))?;
132
133 let body_start = symbol.body_start_byte.ok_or(EditError::NoBodyDefined)?;
134 let body_end = symbol.body_end_byte.ok_or(EditError::NoBodyDefined)?;
135
136 let existing_metadata =
138 fs::metadata(&abs_path).map_err(|e| EditError::Io(abs_path.display().to_string(), e))?;
139 let existing_permissions = existing_metadata.permissions();
140 let existing_bytes =
141 fs::read(&abs_path).map_err(|e| EditError::Io(abs_path.display().to_string(), e))?;
142
143 if body_start > body_end || body_end > existing_bytes.len() {
145 return Err(EditError::InvalidOffsetRange(
146 body_start,
147 body_end,
148 existing_bytes.len(),
149 ));
150 }
151
152 let existing_body =
153 slicer::slice_bytes_safe(&existing_bytes, body_start, body_end).map_err(|e| {
154 EditError::Io(
155 abs_path.display().to_string(),
156 std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()),
157 )
158 })?;
159
160 let current_sha256 = hash_content(existing_body);
161
162 if let Some(expected) = expected_body_hash
164 && expected != current_sha256
165 {
166 return Err(EditError::HashMismatch(
167 expected.to_string(),
168 current_sha256,
169 ));
170 }
171
172 let is_crlf = existing_bytes.windows(2).any(|w| w == b"\r\n");
174 let normalized_body = if is_crlf {
175 let lf_only = new_body.replace("\r\n", "\n");
177 lf_only.replace('\n', "\r\n")
178 } else {
179 new_body.replace("\r\n", "\n")
181 };
182
183 let mut new_file_bytes = Vec::with_capacity(existing_bytes.len() + normalized_body.len());
185 new_file_bytes.extend_from_slice(&existing_bytes[..body_start]);
186 new_file_bytes.extend_from_slice(normalized_body.as_bytes());
187 new_file_bytes.extend_from_slice(&existing_bytes[body_end..]);
188
189 let new_file_str =
191 std::str::from_utf8(&new_file_bytes).map_err(|e| EditError::InvalidUtf8(e.to_string()))?;
192 let syntax_checked = syntax::validate_syntax(&rel_path, new_file_str)?;
193
194 let backup_bytes = existing_bytes.clone();
196
197 let current_disk =
199 fs::read(&abs_path).map_err(|e| EditError::Io(abs_path.display().to_string(), e))?;
200 if current_disk != existing_bytes {
201 return Err(EditError::ConcurrentModification(rel_path));
202 }
203
204 let target_dir = abs_path.parent().unwrap_or(Path::new("."));
206 let mut temp_file = tempfile::Builder::new()
207 .prefix(".code-kb-edit-")
208 .suffix(".tmp")
209 .tempfile_in(target_dir)
210 .map_err(|e| EditError::Io(abs_path.display().to_string(), e))?;
211
212 temp_file
213 .write_all(&new_file_bytes)
214 .map_err(|e| EditError::Io(abs_path.display().to_string(), e))?;
215 temp_file
216 .flush()
217 .map_err(|e| EditError::Io(abs_path.display().to_string(), e))?;
218
219 let _ = temp_file
221 .as_file()
222 .set_permissions(existing_permissions.clone());
223
224 persist_with_retry(temp_file, &abs_path, Some(&existing_bytes)).map_err(|e| {
225 if e.to_string().contains("concurrently modified") {
226 EditError::ConcurrentModification(rel_path.clone())
227 } else {
228 EditError::Io(abs_path.display().to_string(), e)
229 }
230 })?;
231
232 if let Err(err) = sync::update_file(workspace, db_path, &rel_path) {
235 let disk_post_write = fs::read(&abs_path);
237 if disk_post_write.as_deref().ok() != Some(new_file_bytes.as_slice()) {
238 return Err(EditError::ConcurrentModification(format!(
239 "File was concurrently modified during re-indexing; rollback aborted: {err}"
240 )));
241 }
242
243 let rollback_res = (|| -> Result<(), std::io::Error> {
245 let mut rollback_tmp = tempfile::Builder::new()
246 .prefix(".code-kb-rollback-")
247 .suffix(".tmp")
248 .tempfile_in(target_dir)?;
249 rollback_tmp.write_all(&backup_bytes)?;
250 rollback_tmp.flush()?;
251 let _ = rollback_tmp
252 .as_file()
253 .set_permissions(existing_permissions.clone());
254 persist_with_retry(rollback_tmp, &abs_path, Some(&new_file_bytes))?;
255 Ok(())
256 })();
257
258 match rollback_res {
259 Ok(()) => return Err(EditError::SyncWithRollback(err.to_string())),
260 Err(rollback_err) => {
261 return Err(EditError::SyncRollbackFailed {
262 sync_error: err.to_string(),
263 rollback_error: rollback_err.to_string(),
264 });
265 }
266 }
267 }
268
269 let new_body_hash = hash_content(&normalized_body);
270
271 Ok(EditResult {
272 symbol_name: symbol_name.to_string(),
273 file_path: rel_path,
274 old_body_hash: current_sha256,
275 new_body_hash,
276 bytes_written: new_file_bytes.len(),
277 syntax_checked,
278 })
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284
285 #[test]
286 fn test_mixed_crlf_lf_normalization() {
287 let mixed_input = "line1\r\nline2\nline3\r\nline4\n";
288 let lf_only = mixed_input.replace("\r\n", "\n");
290 let crlf_normalized = lf_only.replace('\n', "\r\n");
291 assert_eq!(crlf_normalized, "line1\r\nline2\r\nline3\r\nline4\r\n");
292
293 let lf_normalized = mixed_input.replace("\r\n", "\n");
295 assert_eq!(lf_normalized, "line1\nline2\nline3\nline4\n");
296 }
297
298 #[test]
299 fn test_persist_with_retry_succeeds() {
300 let dir = crate::safe_tempdir();
301 let target_file = dir.path().join("test_persist.txt");
302 fs::write(&target_file, "initial").unwrap();
303
304 let mut temp_file = tempfile::Builder::new()
305 .prefix(".test-persist-")
306 .suffix(".tmp")
307 .tempfile_in(dir.path())
308 .unwrap();
309 temp_file.write_all(b"updated").unwrap();
310 temp_file.flush().unwrap();
311
312 persist_with_retry(temp_file, &target_file, Some(b"initial"))
313 .expect("persist_with_retry must succeed");
314 assert_eq!(fs::read_to_string(&target_file).unwrap(), "updated");
315 }
316}