Skip to main content

winx_code_agent/tools/
file_write_or_edit.rs

1//! Implementation of the `FileWriteOrEdit` tool.
2//!
3//! This module provides the implementation for the `FileWriteOrEdit` tool, which is used
4//! to write or edit files, with support for both full file content and search/replace blocks.
5
6use regex::Regex;
7use sha2::{Digest, Sha256};
8use similar::{ChangeTag, TextDiff};
9use std::fmt::Write as FmtWrite;
10use std::fs;
11use std::io::{BufWriter, Write};
12use std::path::{Path, PathBuf};
13use std::sync::{Arc, OnceLock};
14use tokio::sync::Mutex;
15use tracing::{debug, error, info, instrument, warn};
16
17use crate::errors::{Result, WinxError};
18use crate::state::bash_state::{BashState, FileWhitelistData};
19use crate::types::{normalize_thread_id, FileWriteOrEdit};
20use crate::utils::path::{expand_user, validate_path_in_workspace};
21
22static SEARCH_MARKER: OnceLock<std::result::Result<Regex, regex::Error>> = OnceLock::new();
23static DIVIDER_MARKER: OnceLock<std::result::Result<Regex, regex::Error>> = OnceLock::new();
24static REPLACE_MARKER: OnceLock<std::result::Result<Regex, regex::Error>> = OnceLock::new();
25
26fn regex_marker(
27    marker: &'static OnceLock<std::result::Result<Regex, regex::Error>>,
28    pattern: &'static str,
29) -> Result<&'static Regex> {
30    marker.get_or_init(|| Regex::new(pattern)).as_ref().map_err(|error| {
31        WinxError::ArgumentParseError(format!("Invalid edit marker regex: {error}"))
32    })
33}
34
35fn search_marker() -> Result<&'static Regex> {
36    regex_marker(&SEARCH_MARKER, r"(?m)^<<<<<<+\s*SEARCH>?(?:\s*@(\d+)(?:-(\d+))?)?\s*$")
37}
38
39fn divider_marker() -> Result<&'static Regex> {
40    regex_marker(&DIVIDER_MARKER, r"(?m)^======*\s*$")
41}
42
43fn replace_marker() -> Result<&'static Regex> {
44    regex_marker(&REPLACE_MARKER, r"(?m)^>>>>>>+\s*REPLACE\s*$")
45}
46
47const MAX_FILE_SIZE: u64 = 50_000_000;
48
49#[derive(Debug, Clone, PartialEq, Eq, Default)]
50struct SearchReplaceBlock {
51    search: Vec<String>,
52    replace: Vec<String>,
53    /// Optional 1-based line anchor from a `SEARCH @start[-end]` marker. When
54    /// present, matching prefers candidates starting in this range — disambiguating
55    /// a block that repeats — with a fallback to the normal fuzzy search if the
56    /// anchor matches nothing, so a stale anchor degrades gracefully instead of
57    /// failing the edit.
58    anchor_start: Option<usize>,
59    anchor_end: Option<usize>,
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63enum ToleranceKind {
64    TrimEnd,
65    IgnoreIndentation,
66    RemoveLineNumbers,
67    NormalizeCommonMistakes,
68    IgnoreWhitespace,
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72enum LineMatch {
73    Exact,
74    Tolerated(ToleranceKind),
75}
76
77impl ToleranceKind {
78    fn score(self) -> usize {
79        match self {
80            ToleranceKind::TrimEnd => 1,
81            ToleranceKind::RemoveLineNumbers | ToleranceKind::NormalizeCommonMistakes => 5,
82            ToleranceKind::IgnoreIndentation => 10,
83            ToleranceKind::IgnoreWhitespace => 50,
84        }
85    }
86
87    /// Short human-readable name, surfaced in the success message so the agent
88    /// learns which way its SEARCH text drifted from the file.
89    fn display_name(self) -> &'static str {
90        match self {
91            ToleranceKind::TrimEnd => "trailing whitespace",
92            ToleranceKind::RemoveLineNumbers => "line-number prefixes",
93            ToleranceKind::NormalizeCommonMistakes => "smart-quote/dash normalization",
94            ToleranceKind::IgnoreIndentation => "indentation",
95            ToleranceKind::IgnoreWhitespace => "all whitespace",
96        }
97    }
98}
99
100/// Union of all fuzzy tolerances applied across a set of replacements, in
101/// first-seen order, deduplicated.
102fn collect_tolerances(replacements: &[Replacement]) -> Vec<ToleranceKind> {
103    let mut out: Vec<ToleranceKind> = Vec::new();
104    for replacement in replacements {
105        for &tolerance in &replacement.tolerances {
106            if !out.contains(&tolerance) {
107                out.push(tolerance);
108            }
109        }
110    }
111    out
112}
113
114#[derive(Debug, Clone, PartialEq, Eq)]
115struct MatchCandidate {
116    start: usize,
117    end: usize,
118    score: usize,
119    tolerances: Vec<ToleranceKind>,
120    replace: Vec<String>,
121}
122
123#[derive(Debug, Clone, PartialEq, Eq)]
124struct Replacement {
125    start: usize,
126    end: usize,
127    replace: Vec<String>,
128    /// Fuzzy tolerances applied to land this match (empty = exact). Threaded up
129    /// so the success message can tell the agent its SEARCH text drifted.
130    tolerances: Vec<ToleranceKind>,
131}
132
133fn parse_blocks(content: &str) -> Result<Vec<SearchReplaceBlock>> {
134    let mut blocks = Vec::new();
135    let lines: Vec<&str> = content.lines().collect();
136    let mut i = 0;
137
138    while i < lines.len() {
139        let anchors = search_marker()?.captures(lines[i]).map(|caps| {
140            (
141                caps.get(1).and_then(|m| m.as_str().parse::<usize>().ok()),
142                caps.get(2).and_then(|m| m.as_str().parse::<usize>().ok()),
143            )
144        });
145        if let Some((anchor_start, anchor_end)) = anchors {
146            let line_num = i + 1;
147            i += 1;
148            let mut search_lines = Vec::new();
149            while i < lines.len() && !divider_marker()?.is_match(lines[i]) {
150                if search_marker()?.is_match(lines[i]) || replace_marker()?.is_match(lines[i]) {
151                    return Err(WinxError::SearchReplaceSyntaxError(format!(
152                        "Line {}: stray marker in SEARCH block",
153                        i + 1
154                    )));
155                }
156                search_lines.push(lines[i]);
157                i += 1;
158            }
159
160            if i >= lines.len() {
161                return Err(WinxError::SearchReplaceSyntaxError(format!(
162                    "Line {line_num}: unclosed SEARCH block - missing ======= marker"
163                )));
164            }
165
166            if search_lines.is_empty() {
167                return Err(WinxError::SearchReplaceSyntaxError(format!(
168                    "Line {line_num}: SEARCH block cannot be empty"
169                )));
170            }
171
172            i += 1;
173            let mut replace_lines = Vec::new();
174            while i < lines.len() && !replace_marker()?.is_match(lines[i]) {
175                if search_marker()?.is_match(lines[i]) || divider_marker()?.is_match(lines[i]) {
176                    return Err(WinxError::SearchReplaceSyntaxError(format!(
177                        "Line {}: stray marker in REPLACE block",
178                        i + 1
179                    )));
180                }
181                replace_lines.push(lines[i]);
182                i += 1;
183            }
184
185            if i >= lines.len() {
186                return Err(WinxError::SearchReplaceSyntaxError(format!(
187                    "Line {line_num}: unclosed block - missing REPLACE marker"
188                )));
189            }
190
191            blocks.push(SearchReplaceBlock {
192                search: search_lines.into_iter().map(str::to_string).collect(),
193                replace: replace_lines.into_iter().map(str::to_string).collect(),
194                anchor_start,
195                anchor_end,
196            });
197        } else if divider_marker()?.is_match(lines[i]) || replace_marker()?.is_match(lines[i]) {
198            return Err(WinxError::SearchReplaceSyntaxError(format!(
199                "Line {}: stray marker outside block",
200                i + 1
201            )));
202        }
203        i += 1;
204    }
205
206    if blocks.is_empty() {
207        return Err(WinxError::SearchReplaceSyntaxError("No valid blocks found".to_string()));
208    }
209
210    Ok(blocks)
211}
212
213/// Apply search/replace blocks, retrying once with `\"` unescaped if the first
214/// attempt fails to match. LLMs frequently over-escape quotes in SEARCH text;
215/// wcgw does the same fallback in `do_diff_edit`.
216fn apply_blocks_with_unescape_retry(
217    original: &str,
218    raw: &str,
219) -> Result<(String, Vec<ToleranceKind>)> {
220    let blocks = parse_blocks(raw)?;
221    match apply_blocks(original, &blocks) {
222        Ok(result) => Ok(result),
223        Err(first_err) => {
224            let unescaped = raw.replace("\\\"", "\"");
225            if unescaped == raw {
226                return Err(first_err);
227            }
228            let retry_blocks = parse_blocks(&unescaped).map_err(|_| first_err)?;
229            apply_blocks(original, &retry_blocks)
230        }
231    }
232}
233
234fn apply_blocks(
235    content: &str,
236    blocks: &[SearchReplaceBlock],
237) -> Result<(String, Vec<ToleranceKind>)> {
238    // `parse_blocks` reads the LLM's SEARCH/REPLACE via `.lines()`, which strips
239    // `\r`, so blocks are always LF. Normalize the file to LF too: otherwise a
240    // CRLF line matches only via the TrimEnd tolerance (inflating the score) and
241    // untouched lines keep `\r\n` while replacement lines come back as bare `\n`,
242    // silently turning the file into mixed line endings. Re-apply CRLF on join.
243    let uses_crlf = content.contains("\r\n");
244    let normalized;
245    let content_lf: &str = if uses_crlf {
246        normalized = content.replace("\r\n", "\n");
247        &normalized
248    } else {
249        content
250    };
251
252    let original_lines = split_lines(content_lf);
253    let (edited, tolerances) =
254        apply_blocks_ordered(&original_lines, blocks).or_else(|ordered_error| {
255            if blocks.len() == 1 {
256                Err(ordered_error)
257            } else {
258                apply_blocks_individually(&original_lines, blocks)
259            }
260        })?;
261
262    let joined = edited.join("\n");
263    let content_out = if uses_crlf { joined.replace('\n', "\r\n") } else { joined };
264    Ok((content_out, tolerances))
265}
266
267fn split_lines(content: &str) -> Vec<String> {
268    content.split('\n').map(str::to_string).collect()
269}
270
271/// Atomically write `content` to `path`: write a sibling temp file, fsync it,
272/// then `rename` it over the target.
273///
274/// Two properties this buys us over a plain `truncate`+`write`:
275/// - **Atomicity / crash safety.** The old path truncated the target first and
276///   streamed bytes in; a mid-write failure (ENOSPC, EIO, the process dying)
277///   left the file corrupted or empty. `rename(2)` is atomic within a
278///   filesystem, so a reader sees either the old file or the complete new one,
279///   never a half-written file.
280/// - **Symlink safety.** `validate_path_in_workspace` checks the parent, but a
281///   TOCTOU window lets someone swap the target for a symlink before the write.
282///   `rename` replaces the target entry without following it, so it can't be
283///   redirected outside the workspace — the same guarantee the old `O_NOFOLLOW`
284///   gave, kept here.
285///
286/// The temp file inherits the target's permissions when it already exists, so an
287/// edit doesn't silently flip a 0644 file to the temp's default mode. Non-Unix
288/// targets fall back to `tempfile`'s cross-platform persist.
289/// Create `path`'s parent directories if missing (`mkdir -p`). `path` is already
290/// workspace-confined by `validate_path_in_workspace`, and the components being
291/// created are fresh, so they stay inside the workspace. A residual symlink-swap
292/// TOCTOU on an intermediate dir is the same window `write_no_follow` documents
293/// for the leaf — acceptable on a single-user local server.
294pub(crate) fn ensure_parent_dirs(path: &Path) -> Result<()> {
295    if let Some(parent) = path.parent() {
296        if !parent.as_os_str().is_empty() && !parent.exists() {
297            fs::create_dir_all(parent).map_err(|e| WinxError::FileAccessError {
298                path: path.to_path_buf(),
299                message: format!("Failed to create parent directories: {e}"),
300            })?;
301        }
302    }
303    Ok(())
304}
305
306pub(crate) fn write_no_follow(path: &Path, content: &[u8]) -> std::io::Result<()> {
307    use std::io::Error;
308
309    let parent = path.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new("."));
310    // Random-named temp in the SAME directory so `persist` is a same-filesystem
311    // rename (cross-fs rename would fail with EXDEV). A fresh random name can't
312    // be a pre-planted symlink, so no O_NOFOLLOW is needed on the temp itself.
313    let mut tmp =
314        tempfile::Builder::new().prefix(".winx-tmp-").tempfile_in(parent).map_err(|e| {
315            Error::new(e.kind(), format!("create temp file in {}: {e}", parent.display()))
316        })?;
317
318    // Preserve the existing file's permissions (use symlink_metadata so we read
319    // the target itself, not a symlink's destination).
320    if let Ok(meta) = fs::symlink_metadata(path) {
321        if meta.file_type().is_file() {
322            let _ = tmp.as_file().set_permissions(meta.permissions());
323        }
324    }
325
326    tmp.write_all(content)?;
327    tmp.as_file().sync_all()?;
328    // Atomic replace. `persist` maps to `rename`; on conflict it returns the
329    // temp back inside the error, which we drop (the temp is cleaned up).
330    tmp.persist(path).map_err(|e| e.error)?;
331    Ok(())
332}
333
334/// A single SEARCH block matching more than this many locations is rejected as
335/// too ambiguous, instead of fanning the matcher out exponentially.
336const MAX_CANDIDATES_PER_BLOCK: usize = 64;
337/// Global cap on nodes explored by the ordered matcher — a backstop against the
338/// O(C^B) blow-up when several blocks each match in many places.
339const MAX_SEARCH_NODES: u32 = 50_000;
340/// Refuse to apply an edit whose accumulated tolerance score exceeds this — the
341/// match was forced through too many fuzzy fixups to trust. Mirrors wcgw's
342/// `replace_or_throw` "Too many warnings, not applying" guard.
343const MAX_TOTAL_TOLERANCE_SCORE: usize = 1000;
344
345fn apply_blocks_ordered(
346    lines: &[String],
347    blocks: &[SearchReplaceBlock],
348) -> Result<(Vec<String>, Vec<ToleranceKind>)> {
349    let mut budget = MAX_SEARCH_NODES;
350    let (score, replacements) = best_ordered_replacements(lines, blocks, 0, 0, &mut budget)?;
351    if score > MAX_TOTAL_TOLERANCE_SCORE {
352        return Err(WinxError::SearchBlockNotFound(format!(
353            "SEARCH blocks only matched very loosely (tolerance score {score} over limit \
354             {MAX_TOTAL_TOLERANCE_SCORE}). The file likely changed since you read it — re-read it \
355             and make the SEARCH text match the current content exactly."
356        )));
357    }
358    let tolerances = collect_tolerances(&replacements);
359    Ok((apply_replacements(lines, &replacements), tolerances))
360}
361
362fn best_ordered_replacements(
363    lines: &[String],
364    blocks: &[SearchReplaceBlock],
365    block_index: usize,
366    offset: usize,
367    budget: &mut u32,
368) -> Result<(usize, Vec<Replacement>)> {
369    if block_index >= blocks.len() {
370        return Ok((0, Vec::new()));
371    }
372    if *budget == 0 {
373        return Err(WinxError::SearchBlockNotFound(
374            "Search/replace is too ambiguous (too many candidate combinations). Add more \
375             surrounding context so each SEARCH block matches a unique location."
376                .to_string(),
377        ));
378    }
379    *budget -= 1;
380
381    let block = &blocks[block_index];
382    let candidates = find_candidates(lines, block, offset);
383    if candidates.is_empty() {
384        return Err(not_found_error(block, lines, offset));
385    }
386    if candidates.len() > MAX_CANDIDATES_PER_BLOCK {
387        return Err(WinxError::SearchBlockNotFound(format!(
388            "A SEARCH block matches {} locations (limit {MAX_CANDIDATES_PER_BLOCK}); add more \
389             surrounding context to make it unique:\n{}",
390            candidates.len(),
391            block.search.join("\n")
392        )));
393    }
394
395    let mut valid_paths = Vec::new();
396    for candidate in candidates {
397        if let Ok((tail_score, mut tail)) =
398            best_ordered_replacements(lines, blocks, block_index + 1, candidate.end, budget)
399        {
400            let mut path = vec![Replacement {
401                start: candidate.start,
402                end: candidate.end,
403                replace: candidate.replace,
404                tolerances: candidate.tolerances,
405            }];
406            path.append(&mut tail);
407            valid_paths.push((candidate.score + tail_score, path));
408        }
409    }
410
411    select_unique_best_path(block, valid_paths)
412}
413
414fn select_unique_best_path(
415    block: &SearchReplaceBlock,
416    paths: Vec<(usize, Vec<Replacement>)>,
417) -> Result<(usize, Vec<Replacement>)> {
418    let Some(best_score) = paths.iter().map(|(score, _)| *score).min() else {
419        return Err(WinxError::SearchBlockNotFound(format!(
420            "Block not found: {}",
421            block.search.join("\n")
422        )));
423    };
424
425    let best_paths: Vec<(usize, Vec<Replacement>)> =
426        paths.into_iter().filter(|(score, _)| *score == best_score).collect();
427
428    if best_paths.len() == 1 {
429        return best_paths.into_iter().next().ok_or_else(|| {
430            WinxError::SearchBlockNotFound(format!("Block not found: {}", block.search.join("\n")))
431        });
432    }
433
434    let ranges = best_paths
435        .iter()
436        .filter_map(|(_, reps)| reps.first().map(|r| format!("{}-{}", r.start + 1, r.end)))
437        .collect::<Vec<_>>()
438        .join(", ");
439    Err(WinxError::SearchBlockAmbiguous {
440        block_content: block.search.join("\n"),
441        match_count: best_paths.len(),
442        suggestions: vec![
443            format!("Equally-good matches at lines: {ranges}"),
444            "Add more context before or after this block to make it unique.".to_string(),
445        ],
446    })
447}
448
449fn apply_blocks_individually(
450    lines: &[String],
451    blocks: &[SearchReplaceBlock],
452) -> Result<(Vec<String>, Vec<ToleranceKind>)> {
453    let mut running_lines = lines.to_vec();
454    let mut total_score = 0usize;
455    let mut tolerances: Vec<ToleranceKind> = Vec::new();
456    for block in blocks {
457        let candidate = select_unique_candidate(block, find_candidates(&running_lines, block, 0))?;
458        // Enforce the same fuzzy-fixup ceiling as `apply_blocks_ordered`. Without
459        // this, a multi-block edit that fell back to per-block matching could
460        // apply very loosely-matched blocks the ordered path would have rejected.
461        total_score = total_score.saturating_add(candidate.score);
462        if total_score > MAX_TOTAL_TOLERANCE_SCORE {
463            return Err(WinxError::SearchBlockNotFound(format!(
464                "SEARCH blocks only matched very loosely (tolerance score {total_score} over \
465                 limit {MAX_TOTAL_TOLERANCE_SCORE}). The file likely changed since you read it — \
466                 re-read it and make the SEARCH text match the current content exactly."
467            )));
468        }
469        for &tolerance in &candidate.tolerances {
470            if !tolerances.contains(&tolerance) {
471                tolerances.push(tolerance);
472            }
473        }
474        running_lines = apply_replacements(
475            &running_lines,
476            &[Replacement {
477                start: candidate.start,
478                end: candidate.end,
479                replace: candidate.replace,
480                tolerances: Vec::new(),
481            }],
482        );
483    }
484    Ok((running_lines, tolerances))
485}
486
487fn select_unique_candidate(
488    block: &SearchReplaceBlock,
489    candidates: Vec<MatchCandidate>,
490) -> Result<MatchCandidate> {
491    if candidates.is_empty() {
492        return Err(WinxError::SearchBlockNotFound(format!(
493            "Block not found: {}",
494            block.search.join("\n")
495        )));
496    }
497
498    let best_score = candidates.iter().map(|candidate| candidate.score).min().unwrap_or(0);
499    let best: Vec<MatchCandidate> =
500        candidates.into_iter().filter(|candidate| candidate.score == best_score).collect();
501
502    if best.len() == 1 {
503        return best.into_iter().next().ok_or_else(|| {
504            WinxError::SearchBlockNotFound(format!("Block not found: {}", block.search.join("\n")))
505        });
506    }
507
508    let ranges = best
509        .iter()
510        .map(|candidate| format!("{}-{}", candidate.start + 1, candidate.end))
511        .collect::<Vec<_>>()
512        .join(", ");
513    Err(WinxError::SearchBlockAmbiguous {
514        block_content: block.search.join("\n"),
515        match_count: best.len(),
516        suggestions: vec![
517            format!("Equally-good matches at lines: {ranges}"),
518            "Add more context to make the search block unique.".to_string(),
519        ],
520    })
521}
522
523fn apply_replacements(lines: &[String], replacements: &[Replacement]) -> Vec<String> {
524    let mut edited = Vec::new();
525    let mut cursor = 0;
526
527    for replacement in replacements {
528        edited.extend_from_slice(&lines[cursor..replacement.start]);
529        edited.extend(replacement.replace.clone());
530        cursor = replacement.end;
531    }
532
533    edited.extend_from_slice(&lines[cursor..]);
534    edited
535}
536
537fn find_candidates(
538    lines: &[String],
539    block: &SearchReplaceBlock,
540    offset: usize,
541) -> Vec<MatchCandidate> {
542    let mut candidates = find_contiguous_candidates(lines, block, offset, false);
543    if candidates.is_empty() {
544        candidates = find_single_line_substring_candidates(lines, block, offset);
545    }
546    if candidates.is_empty() {
547        candidates = find_contiguous_candidates(lines, block, offset, true);
548    }
549    narrow_to_anchor(candidates, block)
550}
551
552/// If the block carried a `@start[-end]` line anchor, keep only the candidates
553/// starting in that 1-based range — this is how a repeated block is made
554/// unambiguous. Falls back to the full set when the anchor matched nothing, so a
555/// stale/wrong anchor degrades to the normal search instead of failing the edit.
556fn narrow_to_anchor(
557    candidates: Vec<MatchCandidate>,
558    block: &SearchReplaceBlock,
559) -> Vec<MatchCandidate> {
560    let Some(start) = block.anchor_start else {
561        return candidates;
562    };
563    let lo = start.saturating_sub(1); // 1-based -> 0-based
564    let hi = block.anchor_end.unwrap_or(start).saturating_sub(1);
565    let anchored: Vec<MatchCandidate> =
566        candidates.iter().filter(|c| c.start >= lo && c.start <= hi).cloned().collect();
567    if anchored.is_empty() {
568        candidates
569    } else {
570        anchored
571    }
572}
573
574fn find_single_line_substring_candidates(
575    lines: &[String],
576    block: &SearchReplaceBlock,
577    offset: usize,
578) -> Vec<MatchCandidate> {
579    if block.search.len() != 1 {
580        return Vec::new();
581    }
582
583    let search = &block.search[0];
584    if search.is_empty() {
585        return Vec::new();
586    }
587
588    let replace = block.replace.join("\n");
589    lines
590        .iter()
591        .enumerate()
592        .skip(offset)
593        .flat_map(|(index, line)| {
594            let replace = replace.clone();
595            line.match_indices(search).map(move |(byte_index, _)| {
596                let mut replaced_line = line.clone();
597                replaced_line.replace_range(byte_index..byte_index + search.len(), &replace);
598                MatchCandidate {
599                    start: index,
600                    end: index + 1,
601                    score: 0,
602                    tolerances: Vec::new(),
603                    replace: split_lines(&replaced_line),
604                }
605            })
606        })
607        .collect()
608}
609
610fn find_contiguous_candidates(
611    lines: &[String],
612    block: &SearchReplaceBlock,
613    offset: usize,
614    ignore_empty_lines: bool,
615) -> Vec<MatchCandidate> {
616    let search_lines = if ignore_empty_lines {
617        block.search.iter().filter(|line| !line.trim().is_empty()).cloned().collect()
618    } else {
619        block.search.clone()
620    };
621
622    if search_lines.is_empty() || lines.len().saturating_sub(offset) < search_lines.len() {
623        return Vec::new();
624    }
625
626    if ignore_empty_lines {
627        return find_empty_line_tolerant_candidates(lines, block, offset, &search_lines);
628    }
629
630    let max_start = lines.len() - search_lines.len();
631    (offset..=max_start)
632        .filter_map(|start| {
633            let end = start + search_lines.len();
634            let actual_lines: Vec<&String> = lines[start..end].iter().collect();
635            match_candidate(lines, &actual_lines, &search_lines, block, start, end, false)
636        })
637        .collect()
638}
639
640fn find_empty_line_tolerant_candidates(
641    lines: &[String],
642    block: &SearchReplaceBlock,
643    offset: usize,
644    search_lines: &[String],
645) -> Vec<MatchCandidate> {
646    let compact_lines: Vec<(usize, &String)> =
647        lines.iter().enumerate().skip(offset).filter(|(_, line)| !line.trim().is_empty()).collect();
648
649    if compact_lines.len() < search_lines.len() {
650        return Vec::new();
651    }
652
653    let max_start = compact_lines.len() - search_lines.len();
654    (0..=max_start)
655        .filter_map(|compact_start| {
656            let compact_end = compact_start + search_lines.len();
657            let start = compact_lines[compact_start].0;
658            let end = compact_lines[compact_end - 1].0 + 1;
659            let actual_lines: Vec<&String> =
660                compact_lines[compact_start..compact_end].iter().map(|(_, line)| *line).collect();
661            match_candidate(lines, &actual_lines, search_lines, block, start, end, true)
662        })
663        .collect()
664}
665
666fn match_candidate(
667    lines: &[String],
668    actual_lines: &[&String],
669    search_lines: &[String],
670    block: &SearchReplaceBlock,
671    start: usize,
672    end: usize,
673    ignore_empty_lines: bool,
674) -> Option<MatchCandidate> {
675    let mut tolerances = Vec::new();
676    let mut score = 0;
677
678    for (actual, expected) in actual_lines.iter().zip(search_lines) {
679        let line_match = matching_tolerance(actual, expected)?;
680        if let LineMatch::Tolerated(tolerance) = line_match {
681            score += tolerance.score();
682            if !tolerances.contains(&tolerance) {
683                tolerances.push(tolerance);
684            }
685        }
686    }
687
688    let mut replace = if ignore_empty_lines {
689        trim_empty_edge_lines(&block.replace)
690    } else {
691        block.replace.clone()
692    };
693    if tolerances.contains(&ToleranceKind::RemoveLineNumbers) {
694        replace = replace.into_iter().map(|line| remove_leading_line_number(&line)).collect();
695    }
696    if tolerances.contains(&ToleranceKind::IgnoreIndentation) {
697        let matched = &lines[start..end];
698        replace = fix_indentation(matched, search_lines, &replace);
699    }
700
701    Some(MatchCandidate { start, end, score, tolerances, replace })
702}
703
704fn matching_tolerance(actual: &str, expected: &str) -> Option<LineMatch> {
705    if actual == expected {
706        return Some(LineMatch::Exact);
707    }
708    if actual.trim_end() == expected.trim_end() {
709        return Some(LineMatch::Tolerated(ToleranceKind::TrimEnd));
710    }
711    if actual.trim_start() == expected.trim_start() {
712        return Some(LineMatch::Tolerated(ToleranceKind::IgnoreIndentation));
713    }
714    if remove_leading_line_number(actual) == remove_leading_line_number(expected) {
715        return Some(LineMatch::Tolerated(ToleranceKind::RemoveLineNumbers));
716    }
717    if normalize_common_mistakes(actual) == normalize_common_mistakes(expected) {
718        return Some(LineMatch::Tolerated(ToleranceKind::NormalizeCommonMistakes));
719    }
720    if remove_ascii_whitespace(actual) == remove_ascii_whitespace(expected) {
721        return Some(LineMatch::Tolerated(ToleranceKind::IgnoreWhitespace));
722    }
723    None
724}
725
726fn remove_ascii_whitespace(value: &str) -> String {
727    value.chars().filter(|c| !c.is_whitespace()).collect()
728}
729
730fn remove_leading_line_number(value: &str) -> String {
731    value
732        .split_once(' ')
733        .filter(|(prefix, _)| !prefix.is_empty() && prefix.chars().all(|c| c.is_ascii_digit()))
734        .map_or_else(|| value.trim_end().to_string(), |(_, rest)| rest.trim_end().to_string())
735}
736
737fn normalize_common_mistakes(value: &str) -> String {
738    let mut normalized = String::with_capacity(value.len());
739    for character in value.chars() {
740        match character {
741            '\u{2018}' | '\u{2019}' | '\u{201b}' | '\u{2032}' => normalized.push('\''),
742            '\u{201a}' => normalized.push(','),
743            '\u{201c}' | '\u{201d}' | '\u{201f}' | '\u{2033}' => normalized.push('"'),
744            '\u{2039}' => normalized.push('<'),
745            '\u{203a}' => normalized.push('>'),
746            '\u{2010}' | '\u{2011}' | '\u{2012}' | '\u{2013}' | '\u{2014}' | '\u{2015}'
747            | '\u{2212}' => normalized.push('-'),
748            '\u{2026}' => normalized.push_str("..."),
749            other => normalized.push(other),
750        }
751    }
752    normalized.trim_end().to_string()
753}
754
755fn fix_indentation(
756    matched_lines: &[String],
757    searched_lines: &[String],
758    replaced_lines: &[String],
759) -> Vec<String> {
760    if matched_lines.is_empty() || searched_lines.is_empty() || replaced_lines.is_empty() {
761        return replaced_lines.to_vec();
762    }
763
764    let matched_indents = non_empty_indents(matched_lines);
765    let searched_indents = non_empty_indents(searched_lines);
766    if matched_indents.len() != searched_indents.len() || matched_indents.is_empty() {
767        return replaced_lines.to_vec();
768    }
769
770    // Count by chars, not bytes: indentation can contain multibyte whitespace
771    // (NBSP, ideographic space). A byte-based delta would later slice mid-code-point.
772    let diffs: Vec<isize> = matched_indents
773        .iter()
774        .zip(&searched_indents)
775        .map(|(matched, searched)| {
776            searched.chars().count() as isize - matched.chars().count() as isize
777        })
778        .collect();
779    let Some(&first_diff) = diffs.first() else {
780        return replaced_lines.to_vec();
781    };
782    if first_diff == 0 || !diffs.iter().all(|diff| *diff == first_diff) {
783        return replaced_lines.to_vec();
784    }
785
786    adjust_replacement_indentation(replaced_lines, &matched_indents[0], first_diff)
787}
788
789fn non_empty_indents(lines: &[String]) -> Vec<String> {
790    lines
791        .iter()
792        .filter(|line| !line.trim().is_empty())
793        .map(|line| line.chars().take_while(|c| c.is_whitespace()).collect())
794        .collect()
795}
796
797fn adjust_replacement_indentation(
798    replaced_lines: &[String],
799    matched_indent: &str,
800    diff: isize,
801) -> Vec<String> {
802    // `diff`/`prefix_len`/`remove_len` are CHAR counts (see `diffs` in
803    // `fix_indentation`), so all slicing here goes through char iterators —
804    // never raw byte indices that could split a multibyte whitespace char.
805    if diff < 0 {
806        let prefix_len = usize::try_from(-diff).unwrap_or(0);
807        let prefix: String = matched_indent.chars().take(prefix_len).collect();
808        return replaced_lines.iter().map(|line| format!("{prefix}{line}")).collect();
809    }
810
811    let remove_len = usize::try_from(diff).unwrap_or(0);
812    if !replaced_lines.iter().all(|line| removable_indent(line, remove_len)) {
813        return replaced_lines.to_vec();
814    }
815    replaced_lines.iter().map(|line| line.chars().skip(remove_len).collect()).collect()
816}
817
818fn removable_indent(line: &str, remove_len: usize) -> bool {
819    line.chars().take(remove_len).filter(|c| c.is_whitespace()).count() == remove_len
820}
821
822fn trim_empty_edge_lines(lines: &[String]) -> Vec<String> {
823    let Some(first) = lines.iter().position(|line| !line.trim().is_empty()) else {
824        return Vec::new();
825    };
826    let last = lines.iter().rposition(|line| !line.trim().is_empty()).unwrap_or(first);
827    lines[first..=last].to_vec()
828}
829
830/// Lines of surrounding context shown around the closest match (wcgw parity:
831/// `find_least_edit_distance_substring` returns the match ± 10 lines).
832const SNIPPET_CONTEXT_LINES: usize = 10;
833
834fn not_found_error(block: &SearchReplaceBlock, lines: &[String], offset: usize) -> WinxError {
835    let (snippet, similarity) = closest_snippet(lines, offset, &block.search);
836    WinxError::SearchBlockNotFound(format!(
837        "Block not found in file. The SEARCH block below didn't match anywhere:\n{}\n\n\
838         Closest matching context in the file ({:.0}% similar; lines marked ~ are the ones that \
839         diverged from your SEARCH — re-read the file and copy the text exactly):\n{}",
840        block.search.join("\n"),
841        similarity * 100.0,
842        snippet
843    ))
844}
845
846/// Returns the ±context snippet of the closest match plus its similarity in
847/// `[0,1]`, so the error can tell the model *how close* it got — a 95% near-miss
848/// (stale read) reads very differently from a 20% one (wrong file/block).
849fn closest_snippet(lines: &[String], offset: usize, search: &[String]) -> (String, f64) {
850    let window = search.len().max(1);
851    // A SEARCH block longer than the whole file can't match anywhere; bail before
852    // the loop slices `lines[start..start + window]` past the end and panics. Under
853    // `panic = "abort"` that slice-panic is a one-tool-call DoS of the MCP server
854    // (e.g. a 2-line SEARCH against a 1-line file: max_start saturates to 0, the
855    // loop still fires once and indexes `lines[0..2]`).
856    if lines.is_empty() || offset >= lines.len() || window > lines.len() {
857        return (String::new(), 0.0);
858    }
859
860    let max_start = lines.len().saturating_sub(window);
861    let mut best_start = offset;
862    let mut best_score = f64::MIN;
863    for start in offset..=max_start {
864        let score = snippet_similarity(&lines[start..(start + window)], search);
865        if score > best_score {
866            best_score = score;
867            best_start = start;
868        }
869    }
870
871    // Widen to ±10 lines around the best match so the model can locate it, with
872    // 1-based line numbers (the file is shown numbered elsewhere too).
873    let context_start = best_start.saturating_sub(SNIPPET_CONTEXT_LINES);
874    let context_end = (best_start + window + SNIPPET_CONTEXT_LINES).min(lines.len());
875    let snippet = lines[context_start..context_end]
876        .iter()
877        .enumerate()
878        .map(|(index, line)| {
879            let abs = context_start + index;
880            // Mark with '~' the matched-window lines that diverged most from the
881            // SEARCH at that position, so the model sees exactly WHICH lines
882            // drifted — not just the block as a whole. Context lines aren't marked.
883            let marker = if abs >= best_start && abs < best_start + window {
884                let search_line = &search[abs - best_start];
885                if strsim::normalized_levenshtein(line.trim(), search_line.trim()) < 0.6 {
886                    '~'
887                } else {
888                    ' '
889                }
890            } else {
891                ' '
892            };
893            format!("{:>6} {marker} {line}", abs + 1)
894        })
895        .collect::<Vec<_>>()
896        .join("\n");
897    // `best_score` sums per-line normalized Levenshtein (0..1 each) minus a
898    // length penalty; divide by the window for an average similarity in [0,1].
899    let similarity = (best_score / window as f64).clamp(0.0, 1.0);
900    (snippet, similarity)
901}
902
903fn snippet_similarity(candidate: &[String], search: &[String]) -> f64 {
904    candidate
905        .iter()
906        .zip(search)
907        .map(|(candidate_line, search_line)| {
908            strsim::normalized_levenshtein(candidate_line.trim(), search_line.trim())
909        })
910        .sum::<f64>()
911        - candidate.len().abs_diff(search.len()) as f64
912}
913
914fn uses_search_replace(percentage_to_change: u32, blocks: &str) -> bool {
915    if percentage_to_change <= 50 {
916        return true;
917    }
918
919    let first_content_line = blocks.trim_start().lines().next();
920    first_content_line.is_some_and(|line| search_marker().is_ok_and(|marker| marker.is_match(line)))
921}
922
923pub(crate) fn hash_content(content: &str) -> String {
924    let digest = Sha256::digest(content.as_bytes());
925    digest.iter().fold(String::with_capacity(digest.len() * 2), |mut hash, byte| {
926        let _ = write!(hash, "{byte:02x}");
927        hash
928    })
929}
930
931fn format_unread_ranges(whitelist: &FileWhitelistData) -> String {
932    whitelist
933        .get_unread_ranges()
934        .into_iter()
935        .map(|(start, end)| if start == end { start.to_string() } else { format!("{start}-{end}") })
936        .collect::<Vec<_>>()
937        .join(", ")
938}
939
940#[instrument(level = "info", skip(bash_state_arc, file_write_or_edit))]
941pub async fn handle_tool_call(
942    bash_state_arc: &Arc<Mutex<Option<BashState>>>,
943    file_write_or_edit: FileWriteOrEdit,
944) -> Result<String> {
945    let mut bash_state_guard = bash_state_arc.lock().await;
946    let bash_state = bash_state_guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
947
948    let thread_id = normalize_thread_id(&file_write_or_edit.thread_id);
949    if thread_id != bash_state.current_thread_id {
950        return Err(WinxError::ThreadIdMismatch(thread_id));
951    }
952
953    let planned = plan_edit(
954        bash_state,
955        &file_write_or_edit.file_path,
956        file_write_or_edit.percentage_to_change,
957        &file_write_or_edit.text_or_search_replace_blocks,
958    )?;
959    commit_edit(bash_state, planned)
960}
961
962/// A validated, computed edit that has not yet touched disk. Produced by
963/// [`plan_edit`] (all the checks + the in-memory new content) and consumed by
964/// [`commit_edit`] (the write). Splitting the two is what lets `MultiFileEdit`
965/// validate and compute EVERY file before writing ANY of them.
966pub(crate) struct PlannedEdit {
967    path: PathBuf,
968    file_path_str: String,
969    /// "edited" (search/replace) or "wrote" (full content), for the message.
970    action: &'static str,
971    new_content: String,
972    /// Prior on-disk content, for the post-edit diff. `None` for a new file.
973    previous: Option<String>,
974    tolerances: Vec<ToleranceKind>,
975    uses_search_replace: bool,
976}
977
978impl PlannedEdit {
979    /// The workspace-confined target path, for batch error reporting.
980    pub(crate) fn target(&self) -> &str {
981        &self.file_path_str
982    }
983}
984
985/// Validate and compute an edit WITHOUT writing: resolve + workspace-confine the
986/// path, enforce the mode gate, read the current file once, enforce the
987/// hash/read-enough whitelist gate, and apply the search/replace blocks (or take
988/// the full content). Borrows `bash_state` immutably, so a batch can plan every
989/// file before committing any.
990pub(crate) fn plan_edit(
991    bash_state: &BashState,
992    file_path: &str,
993    percentage_to_change: u32,
994    blocks: &str,
995) -> Result<PlannedEdit> {
996    let expanded_path = expand_user(file_path);
997    let path = if Path::new(&expanded_path).is_absolute() {
998        PathBuf::from(&expanded_path)
999    } else {
1000        bash_state.cwd.join(&expanded_path)
1001    };
1002
1003    let path = validate_path_in_workspace(&path, &bash_state.workspace_root)
1004        .map_err(|e| WinxError::PathSecurityError { path: path.clone(), message: e.to_string() })?;
1005
1006    let file_path_str = path.to_string_lossy().to_string();
1007
1008    let uses_search_replace = uses_search_replace(percentage_to_change, blocks);
1009    let operation_allowed = if uses_search_replace {
1010        bash_state.is_file_edit_allowed(&file_path_str)
1011    } else {
1012        bash_state.is_file_write_allowed(&file_path_str)
1013    };
1014
1015    if !operation_allowed {
1016        return Err(WinxError::FileAccessError {
1017            path,
1018            message: "File operation not allowed in current mode.".to_string(),
1019        });
1020    }
1021
1022    // Read the existing file ONCE: the same bytes feed the hash check, the
1023    // search/replace input, and the post-edit diff. Reading it twice opened a
1024    // TOCTOU window where an external write between the hash check and the edit
1025    // would apply the edit to (and diff against) content the hash never vetted.
1026    let pre_write_content: Option<String> = if path.exists() {
1027        Some(fs::read_to_string(&path).map_err(|e| WinxError::FileAccessError {
1028            path: path.clone(),
1029            message: format!("reading existing file before edit: {e}"),
1030        })?)
1031    } else {
1032        None
1033    };
1034
1035    if let Some(original_content) = pre_write_content.as_deref() {
1036        let whitelist =
1037            bash_state.whitelist_for_overwrite.get(&file_path_str).ok_or_else(|| {
1038                WinxError::FileAccessError {
1039                    path: path.clone(),
1040                    message: format!(
1041                        "This file exists but hasn't been read in this session. \
1042                         Call ReadFiles on {file_path_str} first, then retry the edit \
1043                         (winx requires a fresh read so edits are never applied blind)."
1044                    ),
1045                }
1046            })?;
1047        let current_hash = hash_content(original_content);
1048        if whitelist.file_hash != current_hash {
1049            return Err(WinxError::FileAccessError {
1050                path,
1051                message: format!(
1052                    "{file_path_str} changed on disk since you last read it. \
1053                     Call ReadFiles again to get the current content, then retry the edit."
1054                ),
1055            });
1056        }
1057        if !uses_search_replace && !whitelist.is_read_enough() {
1058            return Err(WinxError::FileAccessError {
1059                path,
1060                message: format!(
1061                    "Read more of the file before overwriting. Unread line ranges: {}",
1062                    format_unread_ranges(whitelist)
1063                ),
1064            });
1065        }
1066    }
1067
1068    let (action, new_content, tolerances) = if uses_search_replace {
1069        // Empty when editing a not-yet-existing file; apply_blocks then fails with
1070        // a clear "block not found" rather than a raw I/O error.
1071        let original_content = pre_write_content.as_deref().unwrap_or_default();
1072        let (new_content, tolerances) = apply_blocks_with_unescape_retry(original_content, blocks)?;
1073        ("edited", new_content, tolerances)
1074    } else {
1075        ("wrote", blocks.to_string(), Vec::new())
1076    };
1077
1078    Ok(PlannedEdit {
1079        path,
1080        file_path_str,
1081        action,
1082        new_content,
1083        previous: pre_write_content,
1084        tolerances,
1085        uses_search_replace,
1086    })
1087}
1088
1089/// Write a [`PlannedEdit`] to disk atomically and refresh the whitelist/stats.
1090/// Returns the success message (including the post-edit diff). This is the only
1091/// step that mutates the filesystem.
1092pub(crate) fn commit_edit(bash_state: &mut BashState, planned: PlannedEdit) -> Result<String> {
1093    let PlannedEdit {
1094        path,
1095        file_path_str,
1096        action,
1097        new_content,
1098        previous,
1099        tolerances,
1100        uses_search_replace,
1101    } = planned;
1102
1103    // `mkdir -p` for new files (no-op for edits, whose parent already exists).
1104    ensure_parent_dirs(&path)?;
1105    write_no_follow(&path, new_content.as_bytes())?;
1106
1107    // Record an undo checkpoint AFTER the write succeeded (never a phantom one for
1108    // a failed edit). Only existing files: a brand-new file has no prior content
1109    // to restore, so its creation is intentionally not undoable.
1110    if let Some(prior_content) = &previous {
1111        let prior_whitelist = bash_state.whitelist_for_overwrite.get(&file_path_str).cloned();
1112        bash_state.push_edit_checkpoint(crate::state::bash_state::EditCheckpoint {
1113            file_path_str: file_path_str.clone(),
1114            path: path.clone(),
1115            prior_content: prior_content.clone(),
1116            prior_whitelist,
1117        });
1118    }
1119
1120    let result = operation_result(
1121        action,
1122        &file_path_str,
1123        &path,
1124        &new_content,
1125        &tolerances,
1126        previous.as_deref(),
1127    );
1128    refresh_whitelist_and_stats(
1129        bash_state,
1130        file_path_str,
1131        &path,
1132        &new_content,
1133        uses_search_replace,
1134    );
1135    Ok(result)
1136}
1137
1138/// After a successful write, re-read the file to re-whitelist it at its new hash
1139/// (so a follow-up edit sees a fresh, fully-read entry) and record the
1140/// edit/write in the workspace stats. Stats failures are non-fatal — they only
1141/// feed heuristics, never correctness.
1142fn refresh_whitelist_and_stats(
1143    bash_state: &mut BashState,
1144    file_path_str: String,
1145    path: &Path,
1146    new_content: &str,
1147    uses_search_replace: bool,
1148) {
1149    // Hash the content we just wrote, in memory, instead of reading the file back
1150    // off disk: a read-back both wastes an IO and opens a TOCTOU window where an
1151    // external write between our atomic rename and the read-back would record a
1152    // hash for content winx never produced (and could even fail with `?` after the
1153    // write already succeeded). `write_no_follow` wrote exactly these bytes, so a
1154    // later ReadFiles sees them and the hash matches.
1155    let hash = hash_content(new_content);
1156    let total_lines = new_content.lines().count();
1157    bash_state
1158        .whitelist_for_overwrite
1159        .insert(file_path_str, FileWhitelistData::new(hash, vec![(1, total_lines)], total_lines));
1160
1161    let (kind, stats) = if uses_search_replace {
1162        ("edit", crate::utils::workspace_stats::record_edit(&bash_state.workspace_root, path))
1163    } else {
1164        ("write", crate::utils::workspace_stats::record_write(&bash_state.workspace_root, path))
1165    };
1166    if let Err(e) = stats {
1167        debug!("failed to record {kind} stats: {e}");
1168    }
1169}
1170
1171/// Lines of context shown around each hunk in the post-edit diff.
1172const DIFF_CONTEXT_LINES: usize = 3;
1173/// Cap on the rendered diff. Past this, the success message carries only the
1174/// `+added/-removed` line summary so a wholesale rewrite can't flood the model's
1175/// context with a giant diff.
1176const MAX_DIFF_LINES: usize = 200;
1177/// Combined `previous + current` byte ceiling for running the (super-linear)
1178/// Myers diff at all. Beyond it, only the net line-count change is reported.
1179const MAX_DIFF_INPUT_BYTES: usize = 512 * 1024;
1180
1181/// A compact unified diff of the change just written, for the success message so
1182/// the agent sees exactly what landed (catching a fuzzy match that hit the wrong
1183/// spot, or an edit that did nothing). `None` when the content is byte-identical
1184/// (a no-op write). A diff longer than `MAX_DIFF_LINES` collapses to its
1185/// `+added/-removed` line counts.
1186fn change_summary(previous: &str, current: &str) -> Option<String> {
1187    if previous == current {
1188        return None;
1189    }
1190    // The line-level Myers diff is super-linear; files can be up to MAX_FILE_SIZE
1191    // (50 MB). Above this combined size, skip it and report only the net
1192    // line-count change (an O(n) scan) so a big rewrite can't burn seconds/RAM.
1193    if previous.len().saturating_add(current.len()) > MAX_DIFF_INPUT_BYTES {
1194        let (before, after) = (previous.lines().count(), current.lines().count());
1195        return Some(format!("Changes: {before} -> {after} lines (file too large to diff)"));
1196    }
1197    let diff = TextDiff::from_lines(previous, current);
1198    let (mut added, mut removed) = (0usize, 0usize);
1199    for change in diff.iter_all_changes() {
1200        match change.tag() {
1201            ChangeTag::Insert => added += 1,
1202            ChangeTag::Delete => removed += 1,
1203            ChangeTag::Equal => {}
1204        }
1205    }
1206    let rendered = diff.unified_diff().context_radius(DIFF_CONTEXT_LINES).to_string();
1207    if rendered.lines().count() > MAX_DIFF_LINES {
1208        return Some(format!("Changes: +{added} -{removed} lines (diff too large to show)"));
1209    }
1210    Some(format!("Changes (+{added} -{removed}):\n{}", rendered.trim_end()))
1211}
1212
1213fn operation_result(
1214    action: &str,
1215    file_path: &str,
1216    path: &Path,
1217    content: &str,
1218    tolerances: &[ToleranceKind],
1219    previous: Option<&str>,
1220) -> String {
1221    let mut result = format!("Successfully {action} {file_path}");
1222    if !tolerances.is_empty() {
1223        // The edit matched only after fuzzy fixups — tell the agent so it learns
1224        // its SEARCH text had drifted (stale read), instead of silently "winning".
1225        let names = tolerances.iter().map(|t| t.display_name()).collect::<Vec<_>>().join(", ");
1226        let _ = write!(
1227            result,
1228            "\n\nNote: matched after tolerating {names} differences — your SEARCH text didn't \
1229             match the file exactly. Re-read the file if you expected an exact match."
1230        );
1231    }
1232    // Show what actually changed, when we have a prior version to diff against (an
1233    // edit, or an overwrite of an existing file — a brand-new file has none).
1234    if let Some(diff) = previous.and_then(|prev| change_summary(prev, content)) {
1235        let _ = write!(result, "\n\n{diff}");
1236    }
1237    if let Some(warning) = crate::utils::syntax::syntax_warning(path, content) {
1238        let _ = write!(result, "\n\n{warning}");
1239    }
1240    result
1241}
1242
1243#[cfg(test)]
1244mod indentation_tests {
1245    #![allow(clippy::expect_used)]
1246    use super::*;
1247
1248    // The indent fixer used to byte-slice over indentation that can contain
1249    // multibyte whitespace (ideographic space U+3000, NBSP) — a guaranteed
1250    // panic. These pin the char-based behavior.
1251
1252    #[test]
1253    fn closest_snippet_search_longer_than_file_does_not_panic() {
1254        // Regression for the DoS: a SEARCH block longer than the file made the
1255        // loop slice `lines[0..window]` past the end and panic (panic = abort).
1256        let lines = vec!["the only line".to_string()];
1257        let search = vec!["a".to_string(), "b".to_string(), "c".to_string()];
1258        let (snippet, similarity) = closest_snippet(&lines, 0, &search);
1259        assert!(snippet.is_empty());
1260        assert!(similarity.abs() < f64::EPSILON, "expected 0.0 similarity, got {similarity}");
1261    }
1262
1263    #[test]
1264    fn closest_snippet_normal_case_still_finds_a_window() {
1265        // The early-return must not swallow legitimate matches: window <= len.
1266        let lines = vec!["alpha".to_string(), "beta".to_string(), "gamma".to_string()];
1267        let search = vec!["beta".to_string()];
1268        let (snippet, similarity) = closest_snippet(&lines, 0, &search);
1269        assert!(snippet.contains("beta"));
1270        assert!(similarity > 0.0);
1271    }
1272
1273    #[test]
1274    fn fix_indentation_adds_multibyte_indent_without_panic() {
1275        // matched has 2 ideographic spaces, searched has 1 -> diff = -1 (add 1).
1276        let matched = vec!["\u{3000}\u{3000}x".to_string()];
1277        let searched = vec!["\u{3000}x".to_string()];
1278        let replaced = vec!["y".to_string()];
1279        let out = fix_indentation(&matched, &searched, &replaced);
1280        assert_eq!(out, vec!["\u{3000}y".to_string()]);
1281    }
1282
1283    #[test]
1284    fn fix_indentation_removes_multibyte_indent_without_panic() {
1285        // matched has 1 ideographic space, searched has 2 -> diff = +1 (remove 1).
1286        let matched = vec!["\u{3000}x".to_string()];
1287        let searched = vec!["\u{3000}\u{3000}x".to_string()];
1288        let replaced = vec!["\u{3000}y".to_string()];
1289        let out = fix_indentation(&matched, &searched, &replaced);
1290        assert_eq!(out, vec!["y".to_string()]);
1291    }
1292
1293    #[test]
1294    fn apply_blocks_preserves_crlf_endings() -> Result<()> {
1295        // A CRLF file must round-trip as CRLF — not turn into mixed endings where
1296        // the edited line is bare LF while untouched lines keep CRLF.
1297        let content = "line one\r\nline two\r\nline three\r\n";
1298        let block = SearchReplaceBlock {
1299            search: vec!["line two".to_string()],
1300            replace: vec!["line TWO".to_string()],
1301            ..Default::default()
1302        };
1303        let (out, _) = apply_blocks(content, &[block])?;
1304        assert_eq!(out, "line one\r\nline TWO\r\nline three\r\n");
1305        Ok(())
1306    }
1307
1308    #[test]
1309    fn apply_blocks_leaves_lf_files_as_lf() -> Result<()> {
1310        let content = "a\nb\nc\n";
1311        let block = SearchReplaceBlock {
1312            search: vec!["b".to_string()],
1313            replace: vec!["B".to_string()],
1314            ..Default::default()
1315        };
1316        let (out, _) = apply_blocks(content, &[block])?;
1317        assert_eq!(out, "a\nB\nc\n");
1318        assert!(!out.contains('\r'));
1319        Ok(())
1320    }
1321
1322    #[test]
1323    fn apply_blocks_reports_indentation_tolerance() -> Result<()> {
1324        // The file is indented two spaces more than the SEARCH block, so the
1325        // match lands only via the indentation tolerance — which must surface.
1326        let content = "  alpha\n  beta\n";
1327        let block = SearchReplaceBlock {
1328            search: vec!["alpha".to_string(), "beta".to_string()],
1329            replace: vec!["alpha".to_string(), "BETA".to_string()],
1330            ..Default::default()
1331        };
1332        let (_out, tolerances) = apply_blocks(content, &[block])?;
1333        assert!(!tolerances.is_empty(), "indentation mismatch should report a tolerance");
1334        Ok(())
1335    }
1336
1337    #[test]
1338    fn apply_blocks_exact_match_reports_no_tolerances() -> Result<()> {
1339        let content = "a\nb\nc\n";
1340        let block = SearchReplaceBlock {
1341            search: vec!["b".to_string()],
1342            replace: vec!["B".to_string()],
1343            ..Default::default()
1344        };
1345        let (_out, tolerances) = apply_blocks(content, &[block])?;
1346        assert!(tolerances.is_empty(), "exact match must not report tolerances");
1347        Ok(())
1348    }
1349
1350    #[test]
1351    fn anchor_parses_start_and_range() -> Result<()> {
1352        let ranged = parse_blocks("<<<<<<< SEARCH @5-8\nfoo\n=======\nbar\n>>>>>>> REPLACE")?;
1353        assert_eq!(ranged[0].anchor_start, Some(5));
1354        assert_eq!(ranged[0].anchor_end, Some(8));
1355        let single = parse_blocks("<<<<<<< SEARCH @3\nfoo\n=======\nbar\n>>>>>>> REPLACE")?;
1356        assert_eq!(single[0].anchor_start, Some(3));
1357        assert_eq!(single[0].anchor_end, None);
1358        let plain = parse_blocks("<<<<<<< SEARCH\nfoo\n=======\nbar\n>>>>>>> REPLACE")?;
1359        assert_eq!(plain[0].anchor_start, None);
1360        Ok(())
1361    }
1362
1363    #[test]
1364    fn anchor_disambiguates_a_repeated_block() -> Result<()> {
1365        // "x" appears on all three lines; @3 targets only the third.
1366        let content = "x\nx\nx\n";
1367        let raw = "<<<<<<< SEARCH @3\nx\n=======\nY\n>>>>>>> REPLACE";
1368        let (out, _) = apply_blocks_with_unescape_retry(content, raw)?;
1369        assert_eq!(out, "x\nx\nY\n", "anchor @3 must edit only the 3rd line");
1370        Ok(())
1371    }
1372
1373    #[test]
1374    fn stale_anchor_falls_back_to_normal_search() -> Result<()> {
1375        // @99 is out of range; the single "x" must still be found, not failed.
1376        let content = "a\nx\nb\n";
1377        let raw = "<<<<<<< SEARCH @99\nx\n=======\nY\n>>>>>>> REPLACE";
1378        let (out, _) = apply_blocks_with_unescape_retry(content, raw)?;
1379        assert_eq!(out, "a\nY\nb\n", "stale anchor should fall back, not fail");
1380        Ok(())
1381    }
1382
1383    #[test]
1384    fn change_summary_is_none_for_identical_content() {
1385        assert!(change_summary("a\nb\nc\n", "a\nb\nc\n").is_none());
1386    }
1387
1388    #[test]
1389    fn change_summary_shows_diff_and_counts() {
1390        let summary = change_summary("a\nb\nc\n", "a\nB\nc\n").expect("content changed");
1391        assert!(summary.contains("+1 -1"), "line counts missing: {summary}");
1392        assert!(summary.contains("-b"), "removed line missing: {summary}");
1393        assert!(summary.contains("+B"), "added line missing: {summary}");
1394    }
1395
1396    #[test]
1397    fn operation_result_includes_diff_when_previous_differs() {
1398        // .txt avoids a tree-sitter syntax warning muddying the assertion.
1399        let r =
1400            operation_result("edited", "n.txt", Path::new("n.txt"), "a\nB\n", &[], Some("a\nb\n"));
1401        assert!(r.contains("Successfully edited n.txt"));
1402        assert!(r.contains("Changes (+1 -1)"), "diff missing from result: {r}");
1403    }
1404
1405    #[test]
1406    fn operation_result_has_no_diff_for_a_new_file() {
1407        let r = operation_result("wrote", "n.txt", Path::new("n.txt"), "hello\n", &[], None);
1408        assert!(r.contains("Successfully wrote n.txt"));
1409        assert!(!r.contains("Changes"), "new file should carry no diff: {r}");
1410    }
1411
1412    #[test]
1413    fn change_summary_skips_myers_on_oversized_input() {
1414        // Over the byte ceiling -> cheap line-count summary, never the Myers diff.
1415        let big = "x\n".repeat(MAX_DIFF_INPUT_BYTES);
1416        let summary = change_summary("", &big).expect("content changed");
1417        assert!(summary.contains("file too large to diff"), "should skip Myers: {summary}");
1418    }
1419
1420    #[test]
1421    fn change_summary_collapses_a_huge_diff() {
1422        // A from-scratch write of far more than MAX_DIFF_LINES lines must not
1423        // inline the whole thing — only the +added/-removed summary.
1424        let big: String = (0..MAX_DIFF_LINES + 50).fold(String::new(), |mut s, i| {
1425            let _ = writeln!(s, "line {i}");
1426            s
1427        });
1428        let summary = change_summary("", &big).expect("content changed");
1429        assert!(summary.contains("diff too large to show"), "should collapse: {summary}");
1430        assert!(!summary.contains("line 10"), "must not inline content: {summary}");
1431    }
1432}