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