Skip to main content

code_kb_core/
edit.rs

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/// Result of an atomic symbol body replacement.
85#[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
113/// Computes SHA256 of a string content.
114pub 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            // ERROR_ACCESS_DENIED = 5, ERROR_SHARING_VIOLATION = 32, ERROR_LOCK_VIOLATION = 33
125            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
164/// Validates, writes, and re-indexes `new_file_bytes` as the whole content of `abs_path`.
165/// Returns whether the extractor checked the syntax. Rolls the file back when re-indexing fails.
166fn 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
245/// Atomically replaces the implementation body of a symbol by name.
246pub 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    // Tier 2: Refresh file in index before querying symbol offsets, propagating any sync errors
261    sync::ensure_fresh_file(workspace, db_path, conn, &rel_path)?;
262
263    // Find symbol in database with exact path
264    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    // Read existing file metadata (permissions) and content
271    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    // Bounds check to prevent out-of-bounds panics
278    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    // Verify optimistic lock if caller specified expected_body_hash
297    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    // Match file line endings (CRLF vs LF)
307    let is_crlf = existing_bytes.windows(2).any(|w| w == b"\r\n");
308    let normalized_body = if is_crlf {
309        // Uniformly normalize all line endings to CRLF, including mixed inputs
310        let lf_only = new_body.replace("\r\n", "\n");
311        lf_only.replace('\n', "\r\n")
312    } else {
313        // Uniformly normalize all line endings to LF, including mixed inputs
314        new_body.replace("\r\n", "\n")
315    };
316
317    // Construct new file content with replaced byte span
318    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
345/// Largest file `edit_file` reads, in bytes.
346const MAX_EDIT_FILE_BYTES: usize = 8 * 1024 * 1024;
347
348/// Which match `edit_file` replaces when `old_text` occurs more than once.
349#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
350#[serde(rename_all = "lowercase")]
351pub enum Occurrence {
352    /// Replace the only match, and refuse when there is more than one.
353    #[default]
354    Only,
355    First,
356    Last,
357    All,
358}
359
360/// How `edit_file` found `old_text` in the file.
361#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
362#[serde(rename_all = "lowercase")]
363pub enum MatchTier {
364    /// The bytes of `old_text` are in the file.
365    Exact,
366    /// The lines of `old_text` are in the file, with other indentation.
367    Whitespace,
368}
369
370/// Result of an atomic text edit.
371#[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
397/// One place `old_text` occurs, with the indentation of the first matched file line.
398struct 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
408/// Every start where the bytes of `old_text` occur, overlapping starts included, so an
409/// ambiguity check sees `ana` twice in `banana`.
410fn 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
460/// Matches the lines of `old_text` against whole file lines, with both ends of each line trimmed.
461/// Every window start is reported, overlapping windows included.
462fn 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
495/// The three file lines that share the longest opening with `probe`, so the caller sees
496/// what the file holds where the match failed.
497fn 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
549/// Atomically replaces `old_text` with `new_text` in one file.
550///
551/// The file is read from disk, so the caller needs no index of its content. An exact match wins;
552/// when there is none, the lines of `old_text` are matched with their indentation ignored.
553pub 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        // When file is CRLF:
690        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        // When file is LF:
695        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}