1use rusqlite::Connection;
2use serde::{Deserialize, Serialize};
3use sha2::{Digest, Sha256};
4use std::borrow::Cow;
5use std::fs;
6use std::io::Write;
7use std::path::Path;
8use thiserror::Error;
9
10use crate::models::Symbol;
11use crate::queries;
12use crate::slicer;
13use crate::sync;
14use crate::syntax::{self, SyntaxError};
15use crate::workspace::Workspace;
16
17#[derive(Debug, Error)]
18pub enum EditError {
19 #[error("Symbol '{name}' not found in {workspace}. {hint}")]
20 SymbolNotFound {
21 name: String,
22 workspace: String,
23 hint: String,
24 },
25 #[error("File '{path}' not found in {workspace}. {hint}")]
26 FileNotFound {
27 path: String,
28 workspace: String,
29 hint: String,
30 },
31 #[error("Symbol has no body defined (e.g. trait declaration without default implementation)")]
32 NoBodyDefined,
33 #[error("Optimistic lock failed: expected body hash '{0}', found '{1}'")]
34 HashMismatch(String, String),
35 #[error(
36 "File offsets out of bounds: range [{0}..{1}], but file length is {2} bytes (file may have shrunk or changed)"
37 )]
38 InvalidOffsetRange(usize, usize, usize),
39 #[error("Pre-flight syntax validation failed: {0}")]
40 Syntax(#[from] SyntaxError),
41 #[error("Replacement content is not valid UTF-8: {0}")]
42 InvalidUtf8(String),
43 #[error("Failed to read/write file '{0}': {1}")]
44 Io(String, #[source] std::io::Error),
45 #[error("Synchronization failed and file was rolled back: {0}")]
46 SyncWithRollback(String),
47 #[error(
48 "Synchronization failed after edit ({sync_error}) and rollback also failed: {rollback_error}"
49 )]
50 SyncRollbackFailed {
51 sync_error: String,
52 rollback_error: String,
53 },
54 #[error("File '{0}' was concurrently modified; edit aborted")]
55 ConcurrentModification(String),
56 #[error("Synchronization failed after edit: {0}")]
57 Sync(#[from] sync::SyncError),
58 #[error("Workspace path error: {0}")]
59 Workspace(#[from] crate::workspace::WorkspaceError),
60 #[error("Query error: {0}")]
61 Query(#[from] queries::QueryError),
62 #[error("The file '{0}' has no match for old_text. {1}")]
63 NoMatch(String, String),
64 #[error(
65 "The file '{path}' has more than one match for old_text, at lines {lines}. Pass occurrence, or add more context lines.",
66 path = .0,
67 lines = ambiguous_lines(.1)
68 )]
69 AmbiguousMatch(String, Vec<usize>),
70 #[error(
71 "The edit would make the file more than 8388608 bytes. edit_file writes files up to that size."
72 )]
73 EditTooLarge,
74 #[error("old_text is empty. Give the text to replace.")]
75 EmptyOldText,
76 #[error("old_text and new_text are the same. The file needs no edit.")]
77 NoChange,
78 #[error("The file is {0} bytes. edit_file reads files up to 8388608 bytes.")]
79 FileTooLarge(usize),
80 #[error("The path '{0}' is not a file.")]
81 NotAFile(String),
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct EditResult {
87 pub symbol_name: String,
88 pub file_path: String,
89 pub old_body_hash: String,
90 pub new_body_hash: String,
91 pub bytes_written: usize,
92 pub syntax_checked: bool,
93}
94
95fn symbol_not_found(conn: &Connection, name: &str, path_filter: Option<&str>) -> EditError {
96 let (workspace, hint) = queries::symbol_not_found_parts(conn, name, path_filter);
97 EditError::SymbolNotFound {
98 name: name.to_string(),
99 workspace,
100 hint,
101 }
102}
103
104fn file_not_found(conn: &Connection, rel_path: &str) -> EditError {
105 let (workspace, hint) = queries::file_not_found_parts(conn, rel_path);
106 EditError::FileNotFound {
107 path: rel_path.to_string(),
108 workspace,
109 hint,
110 }
111}
112
113pub fn hash_content(content: &str) -> String {
115 let mut hasher = Sha256::new();
116 hasher.update(content.as_bytes());
117 hex::encode(hasher.finalize())
118}
119
120fn is_transient_lock_error(err: &std::io::Error) -> bool {
121 #[cfg(windows)]
122 {
123 if let Some(code) = err.raw_os_error() {
124 if code == 5 || code == 32 || code == 33 {
126 return true;
127 }
128 }
129 }
130 matches!(err.kind(), std::io::ErrorKind::PermissionDenied)
131}
132
133fn persist_with_retry(
134 mut temp_file: tempfile::NamedTempFile,
135 dest: &Path,
136 expected_dest_bytes: Option<&[u8]>,
137) -> Result<(), std::io::Error> {
138 for attempt in 0..5 {
139 if attempt > 0
140 && let Some(expected) = expected_dest_bytes
141 && let Ok(current) = fs::read(dest)
142 && current != expected
143 {
144 return Err(std::io::Error::other(
145 "Destination file was concurrently modified during retry",
146 ));
147 }
148 match temp_file.persist(dest) {
149 Ok(_) => return Ok(()),
150 Err(e) => {
151 let is_transient = is_transient_lock_error(&e.error);
152 temp_file = e.file;
153 if is_transient && attempt < 4 {
154 std::thread::sleep(std::time::Duration::from_millis(10 * (1 << attempt)));
155 continue;
156 }
157 return Err(e.error);
158 }
159 }
160 }
161 unreachable!()
162}
163
164fn commit_file_edit(
167 workspace: &Workspace,
168 db_path: &Path,
169 abs_path: &Path,
170 rel_path: &str,
171 existing_bytes: &[u8],
172 existing_permissions: &fs::Permissions,
173 new_file_bytes: &[u8],
174) -> Result<bool, EditError> {
175 let new_file_str =
176 std::str::from_utf8(new_file_bytes).map_err(|e| EditError::InvalidUtf8(e.to_string()))?;
177 let syntax_checked = syntax::validate_syntax(rel_path, new_file_str)?;
178
179 let current_disk =
180 fs::read(abs_path).map_err(|e| EditError::Io(abs_path.display().to_string(), e))?;
181 if current_disk != existing_bytes {
182 return Err(EditError::ConcurrentModification(rel_path.to_string()));
183 }
184
185 let target_dir = abs_path.parent().unwrap_or(Path::new("."));
186 let mut temp_file = tempfile::Builder::new()
187 .prefix(".code-kb-edit-")
188 .suffix(".tmp")
189 .tempfile_in(target_dir)
190 .map_err(|e| EditError::Io(abs_path.display().to_string(), e))?;
191
192 temp_file
193 .write_all(new_file_bytes)
194 .map_err(|e| EditError::Io(abs_path.display().to_string(), e))?;
195 temp_file
196 .flush()
197 .map_err(|e| EditError::Io(abs_path.display().to_string(), e))?;
198
199 let _ = temp_file
200 .as_file()
201 .set_permissions(existing_permissions.clone());
202
203 persist_with_retry(temp_file, abs_path, Some(existing_bytes)).map_err(|e| {
204 if e.to_string().contains("concurrently modified") {
205 EditError::ConcurrentModification(rel_path.to_string())
206 } else {
207 EditError::Io(abs_path.display().to_string(), e)
208 }
209 })?;
210
211 if let Err(err) = sync::update_file(workspace, db_path, rel_path) {
212 let disk_post_write = fs::read(abs_path);
213 if disk_post_write.as_deref().ok() != Some(new_file_bytes) {
214 return Err(EditError::ConcurrentModification(format!(
215 "File was concurrently modified during re-indexing; rollback aborted: {err}"
216 )));
217 }
218
219 let rollback_res = (|| -> Result<(), std::io::Error> {
220 let mut rollback_tmp = tempfile::Builder::new()
221 .prefix(".code-kb-rollback-")
222 .suffix(".tmp")
223 .tempfile_in(target_dir)?;
224 rollback_tmp.write_all(existing_bytes)?;
225 rollback_tmp.flush()?;
226 let _ = rollback_tmp
227 .as_file()
228 .set_permissions(existing_permissions.clone());
229 persist_with_retry(rollback_tmp, abs_path, Some(new_file_bytes))?;
230 Ok(())
231 })();
232
233 return match rollback_res {
234 Ok(()) => Err(EditError::SyncWithRollback(err.to_string())),
235 Err(rollback_err) => Err(EditError::SyncRollbackFailed {
236 sync_error: err.to_string(),
237 rollback_error: rollback_err.to_string(),
238 }),
239 };
240 }
241
242 Ok(syntax_checked)
243}
244
245pub fn replace_symbol_body(
247 workspace: &Workspace,
248 db_path: &Path,
249 conn: &Connection,
250 symbol_name: &str,
251 file_path: &str,
252 new_body: &str,
253 expected_body_hash: Option<&str>,
254) -> Result<EditResult, EditError> {
255 let (abs_path, rel_path) = workspace.resolve_path(Path::new(file_path))?;
256 if !abs_path.exists() {
257 return Err(file_not_found(conn, &rel_path));
258 }
259
260 sync::ensure_fresh_file(workspace, db_path, conn, &rel_path)?;
262
263 let symbol = queries::get_symbol_by_name_exact(conn, symbol_name, &rel_path)?
265 .ok_or_else(|| symbol_not_found(conn, symbol_name, Some(&rel_path)))?;
266
267 let body_start = symbol.body_start_byte.ok_or(EditError::NoBodyDefined)?;
268 let body_end = symbol.body_end_byte.ok_or(EditError::NoBodyDefined)?;
269
270 let existing_metadata =
272 fs::metadata(&abs_path).map_err(|e| EditError::Io(abs_path.display().to_string(), e))?;
273 let existing_permissions = existing_metadata.permissions();
274 let existing_bytes =
275 fs::read(&abs_path).map_err(|e| EditError::Io(abs_path.display().to_string(), e))?;
276
277 if body_start > body_end || body_end > existing_bytes.len() {
279 return Err(EditError::InvalidOffsetRange(
280 body_start,
281 body_end,
282 existing_bytes.len(),
283 ));
284 }
285
286 let existing_body =
287 slicer::slice_bytes_safe(&existing_bytes, body_start, body_end).map_err(|e| {
288 EditError::Io(
289 abs_path.display().to_string(),
290 std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()),
291 )
292 })?;
293
294 let current_sha256 = hash_content(existing_body);
295
296 if let Some(expected) = expected_body_hash
298 && expected != current_sha256
299 {
300 return Err(EditError::HashMismatch(
301 expected.to_string(),
302 current_sha256,
303 ));
304 }
305
306 let is_crlf = existing_bytes.windows(2).any(|w| w == b"\r\n");
308 let normalized_body = if is_crlf {
309 let lf_only = new_body.replace("\r\n", "\n");
311 lf_only.replace('\n', "\r\n")
312 } else {
313 new_body.replace("\r\n", "\n")
315 };
316
317 let mut new_file_bytes = Vec::with_capacity(existing_bytes.len() + normalized_body.len());
319 new_file_bytes.extend_from_slice(&existing_bytes[..body_start]);
320 new_file_bytes.extend_from_slice(normalized_body.as_bytes());
321 new_file_bytes.extend_from_slice(&existing_bytes[body_end..]);
322
323 let syntax_checked = commit_file_edit(
324 workspace,
325 db_path,
326 &abs_path,
327 &rel_path,
328 &existing_bytes,
329 &existing_permissions,
330 &new_file_bytes,
331 )?;
332
333 let new_body_hash = hash_content(&normalized_body);
334
335 Ok(EditResult {
336 symbol_name: symbol_name.to_string(),
337 file_path: rel_path,
338 old_body_hash: current_sha256,
339 new_body_hash,
340 bytes_written: new_file_bytes.len(),
341 syntax_checked,
342 })
343}
344
345const MAX_EDIT_FILE_BYTES: usize = 8 * 1024 * 1024;
347
348#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
350#[serde(rename_all = "lowercase")]
351pub enum Occurrence {
352 #[default]
354 Only,
355 First,
356 Last,
357 All,
358}
359
360#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
362#[serde(rename_all = "lowercase")]
363pub enum MatchTier {
364 Exact,
366 Whitespace,
368}
369
370#[derive(Debug, Clone, Serialize, Deserialize)]
372pub struct TextEditResult {
373 pub file_path: String,
374 pub replacements: usize,
375 pub first_line: usize,
376 pub match_tier: MatchTier,
377 pub touched_symbols: Vec<String>,
378 pub syntax_checked: bool,
379 pub bytes_written: usize,
380}
381
382const AMBIGUOUS_LINES_SHOWN: usize = 10;
383
384fn ambiguous_lines(lines: &[usize]) -> String {
385 let shown = lines
386 .iter()
387 .take(AMBIGUOUS_LINES_SHOWN)
388 .map(usize::to_string)
389 .collect::<Vec<_>>()
390 .join(", ");
391 match lines.len().saturating_sub(AMBIGUOUS_LINES_SHOWN) {
392 0 => shown,
393 more => format!("{shown}, and {more} more"),
394 }
395}
396
397struct TextMatch<'a> {
399 start: usize,
400 end: usize,
401 indent: &'a str,
402}
403
404fn leading_whitespace(line: &str) -> &str {
405 &line[..line.len() - line.trim_start().len()]
406}
407
408fn exact_matches<'a>(haystack: &'a str, old_text: &str) -> Vec<TextMatch<'a>> {
411 let step = old_text.chars().next().map_or(1, char::len_utf8);
412 let mut found = Vec::new();
413 let mut from = 0;
414 while let Some(offset) = haystack[from..].find(old_text) {
415 let start = from + offset;
416 found.push(TextMatch {
417 start,
418 end: start + old_text.len(),
419 indent: "",
420 });
421 from = start + step;
422 }
423 found
424}
425
426fn non_overlapping(matches: Vec<TextMatch<'_>>) -> Vec<TextMatch<'_>> {
427 let mut kept: Vec<TextMatch<'_>> = Vec::new();
428 for found in matches {
429 if kept.last().is_none_or(|last| found.start >= last.end) {
430 kept.push(found);
431 }
432 }
433 kept
434}
435
436fn match_lines(text: &str, matches: &[TextMatch<'_>]) -> Vec<usize> {
437 let mut line = 1;
438 let mut cursor = 0;
439 matches
440 .iter()
441 .map(|found| {
442 line += text[cursor..found.start].matches('\n').count();
443 cursor = found.start;
444 line
445 })
446 .collect()
447}
448
449fn reindent(new_text: &str, old_indent: &str, file_indent: &str) -> String {
450 new_text
451 .lines()
452 .map(|line| match line.strip_prefix(old_indent) {
453 Some(rest) if !line.trim().is_empty() => format!("{file_indent}{rest}"),
454 _ => line.to_string(),
455 })
456 .collect::<Vec<_>>()
457 .join("\n")
458}
459
460fn whitespace_matches<'a>(haystack: &'a str, old_text: &str) -> Vec<TextMatch<'a>> {
463 let old_lines: Vec<&str> = old_text.lines().collect();
464 let file_lines: Vec<&str> = haystack.lines().collect();
465 if old_lines.is_empty() || file_lines.len() < old_lines.len() {
466 return Vec::new();
467 }
468 let mut line_starts = vec![0usize];
469 line_starts.extend(haystack.match_indices('\n').map(|(at, _)| at + 1));
470
471 let mut found = Vec::new();
472 for first in 0..=file_lines.len() - old_lines.len() {
473 let same = (0..old_lines.len())
474 .all(|step| file_lines[first + step].trim() == old_lines[step].trim());
475 if !same {
476 continue;
477 }
478 let last = first + old_lines.len() - 1;
479 found.push(TextMatch {
480 start: line_starts[first],
481 end: line_starts[last] + file_lines[last].len(),
482 indent: leading_whitespace(file_lines[first]),
483 });
484 }
485 found
486}
487
488fn common_prefix_len(left: &str, right: &str) -> usize {
489 left.chars()
490 .zip(right.chars())
491 .take_while(|(l, r)| l == r)
492 .count()
493}
494
495fn nearest_lines(haystack: &str, probe: &str) -> String {
498 let probe = probe.trim();
499 let mut scored: Vec<(usize, usize, &str)> = haystack
500 .lines()
501 .enumerate()
502 .map(|(index, line)| (common_prefix_len(line.trim(), probe), index + 1, line))
503 .filter(|(score, _, _)| *score > 0)
504 .collect();
505 if scored.is_empty() {
506 return "No line in the file is similar.".to_string();
507 }
508 scored.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
509 scored.truncate(3);
510 scored.sort_by_key(|(_, line, _)| *line);
511 let mut report = String::from("The nearest lines are:");
512 for (_, line, text) in scored {
513 report.push_str(&format!("\n{line}: {}", text.trim_end()));
514 }
515 report
516}
517
518fn innermost_symbol_names(symbols: &[Symbol], ranges: &[(usize, usize)]) -> Vec<String> {
519 let mut names: Vec<String> = Vec::new();
520 for (first, last) in ranges {
521 let enclosing = symbols
522 .iter()
523 .filter(|symbol| symbol.start_line <= *first && symbol.end_line >= *last);
524 let definitions: Vec<&Symbol> = enclosing
525 .clone()
526 .filter(|symbol| is_definition(symbol))
527 .collect();
528 let candidates = if definitions.is_empty() {
529 enclosing.collect()
530 } else {
531 definitions
532 };
533 let best = candidates
534 .into_iter()
535 .min_by_key(|symbol| symbol.end_line - symbol.start_line);
536 if let Some(symbol) = best
537 && !names.contains(&symbol.name)
538 {
539 names.push(symbol.name.clone());
540 }
541 }
542 names
543}
544
545fn is_definition(symbol: &Symbol) -> bool {
546 queries::DEFINITION_KINDS.contains(&queries::normalize_kind(&symbol.kind).as_str())
547}
548
549pub fn edit_file(
554 workspace: &Workspace,
555 db_path: &Path,
556 conn: &Connection,
557 file_path: &str,
558 old_text: &str,
559 new_text: &str,
560 occurrence: Occurrence,
561) -> Result<TextEditResult, EditError> {
562 if old_text.is_empty() {
563 return Err(EditError::EmptyOldText);
564 }
565 let (abs_path, rel_path) = workspace.resolve_path(Path::new(file_path))?;
566 if !abs_path.exists() {
567 return Err(file_not_found(conn, &rel_path));
568 }
569 if !abs_path.is_file() {
570 return Err(EditError::NotAFile(rel_path));
571 }
572 let metadata =
573 fs::metadata(&abs_path).map_err(|e| EditError::Io(abs_path.display().to_string(), e))?;
574 if metadata.len() as usize > MAX_EDIT_FILE_BYTES {
575 return Err(EditError::FileTooLarge(metadata.len() as usize));
576 }
577 let existing_permissions = metadata.permissions();
578
579 sync::ensure_fresh_file(workspace, db_path, conn, &rel_path)?;
580
581 let existing_bytes =
582 fs::read(&abs_path).map_err(|e| EditError::Io(abs_path.display().to_string(), e))?;
583 let existing_text =
584 std::str::from_utf8(&existing_bytes).map_err(|e| EditError::InvalidUtf8(e.to_string()))?;
585
586 let is_crlf = existing_bytes.windows(2).any(|w| w == b"\r\n");
587 let file_text = existing_text.replace("\r\n", "\n");
588 let old_text = old_text.replace("\r\n", "\n");
589 let new_text = new_text.replace("\r\n", "\n");
590 if old_text == new_text {
591 return Err(EditError::NoChange);
592 }
593
594 let exact = exact_matches(&file_text, &old_text);
595 let (match_tier, matches) = if exact.is_empty() {
596 (
597 MatchTier::Whitespace,
598 whitespace_matches(&file_text, &old_text),
599 )
600 } else {
601 (MatchTier::Exact, exact)
602 };
603
604 let selected: Vec<TextMatch> = match occurrence {
605 Occurrence::Only if matches.len() > 1 => {
606 return Err(EditError::AmbiguousMatch(
607 rel_path,
608 match_lines(&file_text, &matches),
609 ));
610 }
611 Occurrence::Only => matches,
612 Occurrence::All => non_overlapping(matches),
613 Occurrence::First => matches.into_iter().take(1).collect(),
614 Occurrence::Last => matches.into_iter().next_back().into_iter().collect(),
615 };
616 if selected.is_empty() {
617 let probe = old_text.lines().next().unwrap_or(&old_text);
618 return Err(EditError::NoMatch(
619 rel_path,
620 nearest_lines(&file_text, probe),
621 ));
622 }
623
624 let old_indent = leading_whitespace(old_text.lines().next().unwrap_or_default());
625 let mut edited = String::with_capacity(file_text.len() + new_text.len());
626 let mut cursor = 0;
627 let mut newlines = 0;
628 let mut edited_ranges: Vec<(usize, usize)> = Vec::new();
629 for found in &selected {
630 let replacement: Cow<'_, str> = match match_tier {
631 MatchTier::Exact => Cow::Borrowed(new_text.as_str()),
632 MatchTier::Whitespace => Cow::Owned(reindent(&new_text, old_indent, found.indent)),
633 };
634 let gap = &file_text[cursor..found.start];
635 edited.push_str(gap);
636 newlines += gap.matches('\n').count();
637 let first_line = newlines + 1;
638 edited.push_str(&replacement);
639 if edited.len() > MAX_EDIT_FILE_BYTES {
640 return Err(EditError::EditTooLarge);
641 }
642 newlines += replacement.matches('\n').count();
643 let last_line = if replacement.ends_with('\n') {
644 newlines.max(first_line)
645 } else {
646 newlines + 1
647 };
648 edited_ranges.push((first_line, last_line));
649 cursor = found.end;
650 }
651 edited.push_str(&file_text[cursor..]);
652
653 let new_file_bytes = if is_crlf {
654 edited.replace('\n', "\r\n").into_bytes()
655 } else {
656 edited.into_bytes()
657 };
658
659 let syntax_checked = commit_file_edit(
660 workspace,
661 db_path,
662 &abs_path,
663 &rel_path,
664 &existing_bytes,
665 &existing_permissions,
666 &new_file_bytes,
667 )?;
668
669 let symbols = queries::load_file_symbols(conn, &rel_path)?;
670
671 Ok(TextEditResult {
672 file_path: rel_path,
673 replacements: selected.len(),
674 first_line: edited_ranges[0].0,
675 match_tier,
676 touched_symbols: innermost_symbol_names(&symbols, &edited_ranges),
677 syntax_checked,
678 bytes_written: new_file_bytes.len(),
679 })
680}
681
682#[cfg(test)]
683mod tests {
684 use super::*;
685
686 #[test]
687 fn test_mixed_crlf_lf_normalization() {
688 let mixed_input = "line1\r\nline2\nline3\r\nline4\n";
689 let lf_only = mixed_input.replace("\r\n", "\n");
691 let crlf_normalized = lf_only.replace('\n', "\r\n");
692 assert_eq!(crlf_normalized, "line1\r\nline2\r\nline3\r\nline4\r\n");
693
694 let lf_normalized = mixed_input.replace("\r\n", "\n");
696 assert_eq!(lf_normalized, "line1\nline2\nline3\nline4\n");
697 }
698
699 #[test]
700 fn test_persist_with_retry_succeeds() {
701 let dir = crate::safe_tempdir();
702 let target_file = dir.path().join("test_persist.txt");
703 fs::write(&target_file, "initial").unwrap();
704
705 let mut temp_file = tempfile::Builder::new()
706 .prefix(".test-persist-")
707 .suffix(".tmp")
708 .tempfile_in(dir.path())
709 .unwrap();
710 temp_file.write_all(b"updated").unwrap();
711 temp_file.flush().unwrap();
712
713 persist_with_retry(temp_file, &target_file, Some(b"initial"))
714 .expect("persist_with_retry must succeed");
715 assert_eq!(fs::read_to_string(&target_file).unwrap(), "updated");
716 }
717
718 #[test]
719 fn test_commit_file_edit_refuses_bytes_read_before_a_rival_write() {
720 let dir = crate::safe_tempdir();
721 let rel_path = "src/a.rs";
722 let abs_path = dir.path().join(rel_path);
723 fs::create_dir_all(abs_path.parent().unwrap()).unwrap();
724 let on_disk = b"pub fn rival() -> i32 {\n 1\n}\n";
725 fs::write(&abs_path, on_disk).unwrap();
726 let permissions = fs::metadata(&abs_path).unwrap().permissions();
727 let workspace = Workspace::new(dir.path().to_path_buf());
728
729 let res = commit_file_edit(
730 &workspace,
731 &dir.path().join("test.db"),
732 &abs_path,
733 rel_path,
734 b"pub fn stale() -> i32 {\n 0\n}\n",
735 &permissions,
736 b"pub fn edited() -> i32 {\n 0\n}\n",
737 );
738
739 assert!(
740 matches!(res, Err(EditError::ConcurrentModification(_))),
741 "got: {res:?}"
742 );
743 assert_eq!(fs::read(&abs_path).unwrap(), on_disk);
744 }
745}