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*$")
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)]
49struct SearchReplaceBlock {
50    search: Vec<String>,
51    replace: Vec<String>,
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55enum ToleranceKind {
56    TrimEnd,
57    IgnoreIndentation,
58    RemoveLineNumbers,
59    NormalizeCommonMistakes,
60    IgnoreWhitespace,
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64enum LineMatch {
65    Exact,
66    Tolerated(ToleranceKind),
67}
68
69impl ToleranceKind {
70    fn score(self) -> usize {
71        match self {
72            ToleranceKind::TrimEnd => 1,
73            ToleranceKind::RemoveLineNumbers | ToleranceKind::NormalizeCommonMistakes => 5,
74            ToleranceKind::IgnoreIndentation => 10,
75            ToleranceKind::IgnoreWhitespace => 50,
76        }
77    }
78}
79
80#[derive(Debug, Clone, PartialEq, Eq)]
81struct MatchCandidate {
82    start: usize,
83    end: usize,
84    score: usize,
85    tolerances: Vec<ToleranceKind>,
86    replace: Vec<String>,
87}
88
89#[derive(Debug, Clone, PartialEq, Eq)]
90struct Replacement {
91    start: usize,
92    end: usize,
93    replace: Vec<String>,
94}
95
96fn parse_blocks(content: &str) -> Result<Vec<SearchReplaceBlock>> {
97    let mut blocks = Vec::new();
98    let lines: Vec<&str> = content.lines().collect();
99    let mut i = 0;
100
101    while i < lines.len() {
102        if search_marker()?.is_match(lines[i]) {
103            let line_num = i + 1;
104            i += 1;
105            let mut search_lines = Vec::new();
106            while i < lines.len() && !divider_marker()?.is_match(lines[i]) {
107                if search_marker()?.is_match(lines[i]) || replace_marker()?.is_match(lines[i]) {
108                    return Err(WinxError::SearchReplaceSyntaxError(format!(
109                        "Line {}: stray marker in SEARCH block",
110                        i + 1
111                    )));
112                }
113                search_lines.push(lines[i]);
114                i += 1;
115            }
116
117            if i >= lines.len() {
118                return Err(WinxError::SearchReplaceSyntaxError(format!(
119                    "Line {line_num}: unclosed SEARCH block - missing ======= marker"
120                )));
121            }
122
123            if search_lines.is_empty() {
124                return Err(WinxError::SearchReplaceSyntaxError(format!(
125                    "Line {line_num}: SEARCH block cannot be empty"
126                )));
127            }
128
129            i += 1;
130            let mut replace_lines = Vec::new();
131            while i < lines.len() && !replace_marker()?.is_match(lines[i]) {
132                if search_marker()?.is_match(lines[i]) || divider_marker()?.is_match(lines[i]) {
133                    return Err(WinxError::SearchReplaceSyntaxError(format!(
134                        "Line {}: stray marker in REPLACE block",
135                        i + 1
136                    )));
137                }
138                replace_lines.push(lines[i]);
139                i += 1;
140            }
141
142            if i >= lines.len() {
143                return Err(WinxError::SearchReplaceSyntaxError(format!(
144                    "Line {line_num}: unclosed block - missing REPLACE marker"
145                )));
146            }
147
148            blocks.push(SearchReplaceBlock {
149                search: search_lines.into_iter().map(str::to_string).collect(),
150                replace: replace_lines.into_iter().map(str::to_string).collect(),
151            });
152        } else if divider_marker()?.is_match(lines[i]) || replace_marker()?.is_match(lines[i]) {
153            return Err(WinxError::SearchReplaceSyntaxError(format!(
154                "Line {}: stray marker outside block",
155                i + 1
156            )));
157        }
158        i += 1;
159    }
160
161    if blocks.is_empty() {
162        return Err(WinxError::SearchReplaceSyntaxError("No valid blocks found".to_string()));
163    }
164
165    Ok(blocks)
166}
167
168/// Apply search/replace blocks, retrying once with `\"` unescaped if the first
169/// attempt fails to match. LLMs frequently over-escape quotes in SEARCH text;
170/// wcgw does the same fallback in `do_diff_edit`.
171fn apply_blocks_with_unescape_retry(original: &str, raw: &str) -> Result<String> {
172    let blocks = parse_blocks(raw)?;
173    match apply_blocks(original, &blocks) {
174        Ok(content) => Ok(content),
175        Err(first_err) => {
176            let unescaped = raw.replace("\\\"", "\"");
177            if unescaped == raw {
178                return Err(first_err);
179            }
180            let retry_blocks = parse_blocks(&unescaped).map_err(|_| first_err)?;
181            apply_blocks(original, &retry_blocks)
182        }
183    }
184}
185
186fn apply_blocks(content: &str, blocks: &[SearchReplaceBlock]) -> Result<String> {
187    // `parse_blocks` reads the LLM's SEARCH/REPLACE via `.lines()`, which strips
188    // `\r`, so blocks are always LF. Normalize the file to LF too: otherwise a
189    // CRLF line matches only via the TrimEnd tolerance (inflating the score) and
190    // untouched lines keep `\r\n` while replacement lines come back as bare `\n`,
191    // silently turning the file into mixed line endings. Re-apply CRLF on join.
192    let uses_crlf = content.contains("\r\n");
193    let normalized;
194    let content_lf: &str = if uses_crlf {
195        normalized = content.replace("\r\n", "\n");
196        &normalized
197    } else {
198        content
199    };
200
201    let original_lines = split_lines(content_lf);
202    let edited = apply_blocks_ordered(&original_lines, blocks).or_else(|ordered_error| {
203        if blocks.len() == 1 {
204            Err(ordered_error)
205        } else {
206            apply_blocks_individually(&original_lines, blocks)
207        }
208    })?;
209
210    let joined = edited.join("\n");
211    Ok(if uses_crlf { joined.replace('\n', "\r\n") } else { joined })
212}
213
214fn split_lines(content: &str) -> Vec<String> {
215    content.split('\n').map(str::to_string).collect()
216}
217
218/// Atomically write `content` to `path`: write a sibling temp file, fsync it,
219/// then `rename` it over the target.
220///
221/// Two properties this buys us over a plain `truncate`+`write`:
222/// - **Atomicity / crash safety.** The old path truncated the target first and
223///   streamed bytes in; a mid-write failure (ENOSPC, EIO, the process dying)
224///   left the file corrupted or empty. `rename(2)` is atomic within a
225///   filesystem, so a reader sees either the old file or the complete new one,
226///   never a half-written file.
227/// - **Symlink safety.** `validate_path_in_workspace` checks the parent, but a
228///   TOCTOU window lets someone swap the target for a symlink before the write.
229///   `rename` replaces the target entry without following it, so it can't be
230///   redirected outside the workspace — the same guarantee the old `O_NOFOLLOW`
231///   gave, kept here.
232///
233/// The temp file inherits the target's permissions when it already exists, so an
234/// edit doesn't silently flip a 0644 file to the temp's default mode. Non-Unix
235/// targets fall back to `tempfile`'s cross-platform persist.
236fn write_no_follow(path: &Path, content: &[u8]) -> std::io::Result<()> {
237    use std::io::Error;
238
239    let parent = path.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new("."));
240    // Random-named temp in the SAME directory so `persist` is a same-filesystem
241    // rename (cross-fs rename would fail with EXDEV). A fresh random name can't
242    // be a pre-planted symlink, so no O_NOFOLLOW is needed on the temp itself.
243    let mut tmp =
244        tempfile::Builder::new().prefix(".winx-tmp-").tempfile_in(parent).map_err(|e| {
245            Error::new(e.kind(), format!("create temp file in {}: {e}", parent.display()))
246        })?;
247
248    // Preserve the existing file's permissions (use symlink_metadata so we read
249    // the target itself, not a symlink's destination).
250    if let Ok(meta) = fs::symlink_metadata(path) {
251        if meta.file_type().is_file() {
252            let _ = tmp.as_file().set_permissions(meta.permissions());
253        }
254    }
255
256    tmp.write_all(content)?;
257    tmp.as_file().sync_all()?;
258    // Atomic replace. `persist` maps to `rename`; on conflict it returns the
259    // temp back inside the error, which we drop (the temp is cleaned up).
260    tmp.persist(path).map_err(|e| e.error)?;
261    Ok(())
262}
263
264/// A single SEARCH block matching more than this many locations is rejected as
265/// too ambiguous, instead of fanning the matcher out exponentially.
266const MAX_CANDIDATES_PER_BLOCK: usize = 64;
267/// Global cap on nodes explored by the ordered matcher — a backstop against the
268/// O(C^B) blow-up when several blocks each match in many places.
269const MAX_SEARCH_NODES: u32 = 50_000;
270/// Refuse to apply an edit whose accumulated tolerance score exceeds this — the
271/// match was forced through too many fuzzy fixups to trust. Mirrors wcgw's
272/// `replace_or_throw` "Too many warnings, not applying" guard.
273const MAX_TOTAL_TOLERANCE_SCORE: usize = 1000;
274
275fn apply_blocks_ordered(lines: &[String], blocks: &[SearchReplaceBlock]) -> Result<Vec<String>> {
276    let mut budget = MAX_SEARCH_NODES;
277    let (score, replacements) = best_ordered_replacements(lines, blocks, 0, 0, &mut budget)?;
278    if score > MAX_TOTAL_TOLERANCE_SCORE {
279        return Err(WinxError::SearchBlockNotFound(format!(
280            "SEARCH blocks only matched very loosely (tolerance score {score} over limit \
281             {MAX_TOTAL_TOLERANCE_SCORE}). The file likely changed since you read it — re-read it \
282             and make the SEARCH text match the current content exactly."
283        )));
284    }
285    Ok(apply_replacements(lines, &replacements))
286}
287
288fn best_ordered_replacements(
289    lines: &[String],
290    blocks: &[SearchReplaceBlock],
291    block_index: usize,
292    offset: usize,
293    budget: &mut u32,
294) -> Result<(usize, Vec<Replacement>)> {
295    if block_index >= blocks.len() {
296        return Ok((0, Vec::new()));
297    }
298    if *budget == 0 {
299        return Err(WinxError::SearchBlockNotFound(
300            "Search/replace is too ambiguous (too many candidate combinations). Add more \
301             surrounding context so each SEARCH block matches a unique location."
302                .to_string(),
303        ));
304    }
305    *budget -= 1;
306
307    let block = &blocks[block_index];
308    let candidates = find_candidates(lines, block, offset);
309    if candidates.is_empty() {
310        return Err(not_found_error(block, lines, offset));
311    }
312    if candidates.len() > MAX_CANDIDATES_PER_BLOCK {
313        return Err(WinxError::SearchBlockNotFound(format!(
314            "A SEARCH block matches {} locations (limit {MAX_CANDIDATES_PER_BLOCK}); add more \
315             surrounding context to make it unique:\n{}",
316            candidates.len(),
317            block.search.join("\n")
318        )));
319    }
320
321    let mut valid_paths = Vec::new();
322    for candidate in candidates {
323        if let Ok((tail_score, mut tail)) =
324            best_ordered_replacements(lines, blocks, block_index + 1, candidate.end, budget)
325        {
326            let mut path = vec![Replacement {
327                start: candidate.start,
328                end: candidate.end,
329                replace: candidate.replace,
330            }];
331            path.append(&mut tail);
332            valid_paths.push((candidate.score + tail_score, path));
333        }
334    }
335
336    select_unique_best_path(block, valid_paths)
337}
338
339fn select_unique_best_path(
340    block: &SearchReplaceBlock,
341    paths: Vec<(usize, Vec<Replacement>)>,
342) -> Result<(usize, Vec<Replacement>)> {
343    let Some(best_score) = paths.iter().map(|(score, _)| *score).min() else {
344        return Err(WinxError::SearchBlockNotFound(format!(
345            "Block not found: {}",
346            block.search.join("\n")
347        )));
348    };
349
350    let best_paths: Vec<(usize, Vec<Replacement>)> =
351        paths.into_iter().filter(|(score, _)| *score == best_score).collect();
352
353    if best_paths.len() == 1 {
354        return best_paths.into_iter().next().ok_or_else(|| {
355            WinxError::SearchBlockNotFound(format!("Block not found: {}", block.search.join("\n")))
356        });
357    }
358
359    Err(WinxError::SearchBlockAmbiguous {
360        block_content: block.search.join("\n"),
361        match_count: best_paths.len(),
362        suggestions: vec!["Add more context before or after this block.".to_string()],
363    })
364}
365
366fn apply_blocks_individually(
367    lines: &[String],
368    blocks: &[SearchReplaceBlock],
369) -> Result<Vec<String>> {
370    let mut running_lines = lines.to_vec();
371    let mut total_score = 0usize;
372    for block in blocks {
373        let candidate = select_unique_candidate(block, find_candidates(&running_lines, block, 0))?;
374        // Enforce the same fuzzy-fixup ceiling as `apply_blocks_ordered`. Without
375        // this, a multi-block edit that fell back to per-block matching could
376        // apply very loosely-matched blocks the ordered path would have rejected.
377        total_score = total_score.saturating_add(candidate.score);
378        if total_score > MAX_TOTAL_TOLERANCE_SCORE {
379            return Err(WinxError::SearchBlockNotFound(format!(
380                "SEARCH blocks only matched very loosely (tolerance score {total_score} over \
381                 limit {MAX_TOTAL_TOLERANCE_SCORE}). The file likely changed since you read it — \
382                 re-read it and make the SEARCH text match the current content exactly."
383            )));
384        }
385        running_lines = apply_replacements(
386            &running_lines,
387            &[Replacement {
388                start: candidate.start,
389                end: candidate.end,
390                replace: candidate.replace,
391            }],
392        );
393    }
394    Ok(running_lines)
395}
396
397fn select_unique_candidate(
398    block: &SearchReplaceBlock,
399    candidates: Vec<MatchCandidate>,
400) -> Result<MatchCandidate> {
401    if candidates.is_empty() {
402        return Err(WinxError::SearchBlockNotFound(format!(
403            "Block not found: {}",
404            block.search.join("\n")
405        )));
406    }
407
408    let best_score = candidates.iter().map(|candidate| candidate.score).min().unwrap_or(0);
409    let best: Vec<MatchCandidate> =
410        candidates.into_iter().filter(|candidate| candidate.score == best_score).collect();
411
412    if best.len() == 1 {
413        return best.into_iter().next().ok_or_else(|| {
414            WinxError::SearchBlockNotFound(format!("Block not found: {}", block.search.join("\n")))
415        });
416    }
417
418    Err(WinxError::SearchBlockAmbiguous {
419        block_content: block.search.join("\n"),
420        match_count: best.len(),
421        suggestions: vec!["Add more context to make the search block unique.".to_string()],
422    })
423}
424
425fn apply_replacements(lines: &[String], replacements: &[Replacement]) -> Vec<String> {
426    let mut edited = Vec::new();
427    let mut cursor = 0;
428
429    for replacement in replacements {
430        edited.extend_from_slice(&lines[cursor..replacement.start]);
431        edited.extend(replacement.replace.clone());
432        cursor = replacement.end;
433    }
434
435    edited.extend_from_slice(&lines[cursor..]);
436    edited
437}
438
439fn find_candidates(
440    lines: &[String],
441    block: &SearchReplaceBlock,
442    offset: usize,
443) -> Vec<MatchCandidate> {
444    let mut candidates = find_contiguous_candidates(lines, block, offset, false);
445    if candidates.is_empty() {
446        candidates = find_single_line_substring_candidates(lines, block, offset);
447    }
448    if candidates.is_empty() {
449        candidates = find_contiguous_candidates(lines, block, offset, true);
450    }
451    candidates
452}
453
454fn find_single_line_substring_candidates(
455    lines: &[String],
456    block: &SearchReplaceBlock,
457    offset: usize,
458) -> Vec<MatchCandidate> {
459    if block.search.len() != 1 {
460        return Vec::new();
461    }
462
463    let search = &block.search[0];
464    if search.is_empty() {
465        return Vec::new();
466    }
467
468    let replace = block.replace.join("\n");
469    lines
470        .iter()
471        .enumerate()
472        .skip(offset)
473        .flat_map(|(index, line)| {
474            let replace = replace.clone();
475            line.match_indices(search).map(move |(byte_index, _)| {
476                let mut replaced_line = line.clone();
477                replaced_line.replace_range(byte_index..byte_index + search.len(), &replace);
478                MatchCandidate {
479                    start: index,
480                    end: index + 1,
481                    score: 0,
482                    tolerances: Vec::new(),
483                    replace: split_lines(&replaced_line),
484                }
485            })
486        })
487        .collect()
488}
489
490fn find_contiguous_candidates(
491    lines: &[String],
492    block: &SearchReplaceBlock,
493    offset: usize,
494    ignore_empty_lines: bool,
495) -> Vec<MatchCandidate> {
496    let search_lines = if ignore_empty_lines {
497        block.search.iter().filter(|line| !line.trim().is_empty()).cloned().collect()
498    } else {
499        block.search.clone()
500    };
501
502    if search_lines.is_empty() || lines.len().saturating_sub(offset) < search_lines.len() {
503        return Vec::new();
504    }
505
506    if ignore_empty_lines {
507        return find_empty_line_tolerant_candidates(lines, block, offset, &search_lines);
508    }
509
510    let max_start = lines.len() - search_lines.len();
511    (offset..=max_start)
512        .filter_map(|start| {
513            let end = start + search_lines.len();
514            let actual_lines: Vec<&String> = lines[start..end].iter().collect();
515            match_candidate(lines, &actual_lines, &search_lines, block, start, end, false)
516        })
517        .collect()
518}
519
520fn find_empty_line_tolerant_candidates(
521    lines: &[String],
522    block: &SearchReplaceBlock,
523    offset: usize,
524    search_lines: &[String],
525) -> Vec<MatchCandidate> {
526    let compact_lines: Vec<(usize, &String)> =
527        lines.iter().enumerate().skip(offset).filter(|(_, line)| !line.trim().is_empty()).collect();
528
529    if compact_lines.len() < search_lines.len() {
530        return Vec::new();
531    }
532
533    let max_start = compact_lines.len() - search_lines.len();
534    (0..=max_start)
535        .filter_map(|compact_start| {
536            let compact_end = compact_start + search_lines.len();
537            let start = compact_lines[compact_start].0;
538            let end = compact_lines[compact_end - 1].0 + 1;
539            let actual_lines: Vec<&String> =
540                compact_lines[compact_start..compact_end].iter().map(|(_, line)| *line).collect();
541            match_candidate(lines, &actual_lines, search_lines, block, start, end, true)
542        })
543        .collect()
544}
545
546fn match_candidate(
547    lines: &[String],
548    actual_lines: &[&String],
549    search_lines: &[String],
550    block: &SearchReplaceBlock,
551    start: usize,
552    end: usize,
553    ignore_empty_lines: bool,
554) -> Option<MatchCandidate> {
555    let mut tolerances = Vec::new();
556    let mut score = 0;
557
558    for (actual, expected) in actual_lines.iter().zip(search_lines) {
559        let line_match = matching_tolerance(actual, expected)?;
560        if let LineMatch::Tolerated(tolerance) = line_match {
561            score += tolerance.score();
562            if !tolerances.contains(&tolerance) {
563                tolerances.push(tolerance);
564            }
565        }
566    }
567
568    let mut replace = if ignore_empty_lines {
569        trim_empty_edge_lines(&block.replace)
570    } else {
571        block.replace.clone()
572    };
573    if tolerances.contains(&ToleranceKind::RemoveLineNumbers) {
574        replace = replace.into_iter().map(|line| remove_leading_line_number(&line)).collect();
575    }
576    if tolerances.contains(&ToleranceKind::IgnoreIndentation) {
577        let matched = &lines[start..end];
578        replace = fix_indentation(matched, search_lines, &replace);
579    }
580
581    Some(MatchCandidate { start, end, score, tolerances, replace })
582}
583
584fn matching_tolerance(actual: &str, expected: &str) -> Option<LineMatch> {
585    if actual == expected {
586        return Some(LineMatch::Exact);
587    }
588    if actual.trim_end() == expected.trim_end() {
589        return Some(LineMatch::Tolerated(ToleranceKind::TrimEnd));
590    }
591    if actual.trim_start() == expected.trim_start() {
592        return Some(LineMatch::Tolerated(ToleranceKind::IgnoreIndentation));
593    }
594    if remove_leading_line_number(actual) == remove_leading_line_number(expected) {
595        return Some(LineMatch::Tolerated(ToleranceKind::RemoveLineNumbers));
596    }
597    if normalize_common_mistakes(actual) == normalize_common_mistakes(expected) {
598        return Some(LineMatch::Tolerated(ToleranceKind::NormalizeCommonMistakes));
599    }
600    if remove_ascii_whitespace(actual) == remove_ascii_whitespace(expected) {
601        return Some(LineMatch::Tolerated(ToleranceKind::IgnoreWhitespace));
602    }
603    None
604}
605
606fn remove_ascii_whitespace(value: &str) -> String {
607    value.chars().filter(|c| !c.is_whitespace()).collect()
608}
609
610fn remove_leading_line_number(value: &str) -> String {
611    value
612        .split_once(' ')
613        .filter(|(prefix, _)| !prefix.is_empty() && prefix.chars().all(|c| c.is_ascii_digit()))
614        .map_or_else(|| value.trim_end().to_string(), |(_, rest)| rest.trim_end().to_string())
615}
616
617fn normalize_common_mistakes(value: &str) -> String {
618    let mut normalized = String::with_capacity(value.len());
619    for character in value.chars() {
620        match character {
621            '\u{2018}' | '\u{2019}' | '\u{201b}' | '\u{2032}' => normalized.push('\''),
622            '\u{201a}' => normalized.push(','),
623            '\u{201c}' | '\u{201d}' | '\u{201f}' | '\u{2033}' => normalized.push('"'),
624            '\u{2039}' => normalized.push('<'),
625            '\u{203a}' => normalized.push('>'),
626            '\u{2010}' | '\u{2011}' | '\u{2012}' | '\u{2013}' | '\u{2014}' | '\u{2015}'
627            | '\u{2212}' => normalized.push('-'),
628            '\u{2026}' => normalized.push_str("..."),
629            other => normalized.push(other),
630        }
631    }
632    normalized.trim_end().to_string()
633}
634
635fn fix_indentation(
636    matched_lines: &[String],
637    searched_lines: &[String],
638    replaced_lines: &[String],
639) -> Vec<String> {
640    if matched_lines.is_empty() || searched_lines.is_empty() || replaced_lines.is_empty() {
641        return replaced_lines.to_vec();
642    }
643
644    let matched_indents = non_empty_indents(matched_lines);
645    let searched_indents = non_empty_indents(searched_lines);
646    if matched_indents.len() != searched_indents.len() || matched_indents.is_empty() {
647        return replaced_lines.to_vec();
648    }
649
650    // Count by chars, not bytes: indentation can contain multibyte whitespace
651    // (NBSP, ideographic space). A byte-based delta would later slice mid-code-point.
652    let diffs: Vec<isize> = matched_indents
653        .iter()
654        .zip(&searched_indents)
655        .map(|(matched, searched)| {
656            searched.chars().count() as isize - matched.chars().count() as isize
657        })
658        .collect();
659    let first_diff = diffs[0];
660    if first_diff == 0 || !diffs.iter().all(|diff| *diff == first_diff) {
661        return replaced_lines.to_vec();
662    }
663
664    adjust_replacement_indentation(replaced_lines, &matched_indents[0], first_diff)
665}
666
667fn non_empty_indents(lines: &[String]) -> Vec<String> {
668    lines
669        .iter()
670        .filter(|line| !line.trim().is_empty())
671        .map(|line| line.chars().take_while(|c| c.is_whitespace()).collect())
672        .collect()
673}
674
675fn adjust_replacement_indentation(
676    replaced_lines: &[String],
677    matched_indent: &str,
678    diff: isize,
679) -> Vec<String> {
680    // `diff`/`prefix_len`/`remove_len` are CHAR counts (see `diffs` in
681    // `fix_indentation`), so all slicing here goes through char iterators —
682    // never raw byte indices that could split a multibyte whitespace char.
683    if diff < 0 {
684        let prefix_len = usize::try_from(-diff).unwrap_or(0);
685        let prefix: String = matched_indent.chars().take(prefix_len).collect();
686        return replaced_lines.iter().map(|line| format!("{prefix}{line}")).collect();
687    }
688
689    let remove_len = usize::try_from(diff).unwrap_or(0);
690    if !replaced_lines.iter().all(|line| removable_indent(line, remove_len)) {
691        return replaced_lines.to_vec();
692    }
693    replaced_lines.iter().map(|line| line.chars().skip(remove_len).collect()).collect()
694}
695
696fn removable_indent(line: &str, remove_len: usize) -> bool {
697    line.chars().take(remove_len).filter(|c| c.is_whitespace()).count() == remove_len
698}
699
700fn trim_empty_edge_lines(lines: &[String]) -> Vec<String> {
701    let Some(first) = lines.iter().position(|line| !line.trim().is_empty()) else {
702        return Vec::new();
703    };
704    let last = lines.iter().rposition(|line| !line.trim().is_empty()).unwrap_or(first);
705    lines[first..=last].to_vec()
706}
707
708/// Lines of surrounding context shown around the closest match (wcgw parity:
709/// `find_least_edit_distance_substring` returns the match ± 10 lines).
710const SNIPPET_CONTEXT_LINES: usize = 10;
711
712fn not_found_error(block: &SearchReplaceBlock, lines: &[String], offset: usize) -> WinxError {
713    let snippet = closest_snippet(lines, offset, &block.search);
714    WinxError::SearchBlockNotFound(format!(
715        "Block not found in file. The SEARCH block below didn't match anywhere:\n{}\n\n\
716         Closest matching context in the file (with surrounding lines):\n{}",
717        block.search.join("\n"),
718        snippet
719    ))
720}
721
722fn closest_snippet(lines: &[String], offset: usize, search: &[String]) -> String {
723    let window = search.len().max(1);
724    if lines.is_empty() || offset >= lines.len() {
725        return String::new();
726    }
727
728    let max_start = lines.len().saturating_sub(window);
729    let mut best_start = offset;
730    let mut best_score = f64::MIN;
731    for start in offset..=max_start {
732        let score = snippet_similarity(&lines[start..(start + window)], search);
733        if score > best_score {
734            best_score = score;
735            best_start = start;
736        }
737    }
738
739    // Widen to ±10 lines around the best match so the model can locate it, with
740    // 1-based line numbers (the file is shown numbered elsewhere too).
741    let context_start = best_start.saturating_sub(SNIPPET_CONTEXT_LINES);
742    let context_end = (best_start + window + SNIPPET_CONTEXT_LINES).min(lines.len());
743    lines[context_start..context_end]
744        .iter()
745        .enumerate()
746        .map(|(index, line)| format!("{:>6}  {}", context_start + index + 1, line))
747        .collect::<Vec<_>>()
748        .join("\n")
749}
750
751fn snippet_similarity(candidate: &[String], search: &[String]) -> f64 {
752    candidate
753        .iter()
754        .zip(search)
755        .map(|(candidate_line, search_line)| {
756            strsim::normalized_levenshtein(candidate_line.trim(), search_line.trim())
757        })
758        .sum::<f64>()
759        - candidate.len().abs_diff(search.len()) as f64
760}
761
762fn uses_search_replace(file_write_or_edit: &FileWriteOrEdit) -> bool {
763    if file_write_or_edit.percentage_to_change <= 50 {
764        return true;
765    }
766
767    let first_content_line =
768        file_write_or_edit.text_or_search_replace_blocks.trim_start().lines().next();
769    first_content_line.is_some_and(|line| search_marker().is_ok_and(|marker| marker.is_match(line)))
770}
771
772fn hash_content(content: &str) -> String {
773    let digest = Sha256::digest(content.as_bytes());
774    digest.iter().fold(String::with_capacity(digest.len() * 2), |mut hash, byte| {
775        let _ = write!(hash, "{byte:02x}");
776        hash
777    })
778}
779
780fn format_unread_ranges(whitelist: &FileWhitelistData) -> String {
781    whitelist
782        .get_unread_ranges()
783        .into_iter()
784        .map(|(start, end)| if start == end { start.to_string() } else { format!("{start}-{end}") })
785        .collect::<Vec<_>>()
786        .join(", ")
787}
788
789#[instrument(level = "info", skip(bash_state_arc, file_write_or_edit))]
790pub async fn handle_tool_call(
791    bash_state_arc: &Arc<Mutex<Option<BashState>>>,
792    file_write_or_edit: FileWriteOrEdit,
793) -> Result<String> {
794    let mut bash_state_guard = bash_state_arc.lock().await;
795    let bash_state = bash_state_guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
796
797    let thread_id = normalize_thread_id(&file_write_or_edit.thread_id);
798    if thread_id != bash_state.current_thread_id {
799        return Err(WinxError::ThreadIdMismatch(thread_id));
800    }
801
802    let expanded_path = expand_user(&file_write_or_edit.file_path);
803    let path = if Path::new(&expanded_path).is_absolute() {
804        PathBuf::from(&expanded_path)
805    } else {
806        bash_state.cwd.join(&expanded_path)
807    };
808
809    let path = validate_path_in_workspace(&path, &bash_state.workspace_root)
810        .map_err(|e| WinxError::PathSecurityError { path: path.clone(), message: e.to_string() })?;
811
812    let file_path_str = path.to_string_lossy().to_string();
813
814    let uses_search_replace = uses_search_replace(&file_write_or_edit);
815    let operation_allowed = if uses_search_replace {
816        bash_state.is_file_edit_allowed(&file_path_str)
817    } else {
818        bash_state.is_file_write_allowed(&file_path_str)
819    };
820
821    if !operation_allowed {
822        return Err(WinxError::FileAccessError {
823            path: path.clone(),
824            message: "File operation not allowed in current mode.".to_string(),
825        });
826    }
827
828    if path.exists() {
829        let whitelist =
830            bash_state.whitelist_for_overwrite.get(&file_path_str).ok_or_else(|| {
831                WinxError::FileAccessError {
832                    path: path.clone(),
833                    message: format!(
834                        "This file exists but hasn't been read in this session. \
835                         Call ReadFiles on {file_path_str} first, then retry the edit \
836                         (winx requires a fresh read so edits are never applied blind)."
837                    ),
838                }
839            })?;
840        let original_content = fs::read_to_string(&path)?;
841        let current_hash = hash_content(&original_content);
842        if whitelist.file_hash != current_hash {
843            return Err(WinxError::FileAccessError {
844                path: path.clone(),
845                message: format!(
846                    "{file_path_str} changed on disk since you last read it. \
847                     Call ReadFiles again to get the current content, then retry the edit."
848                ),
849            });
850        }
851        if !uses_search_replace && !whitelist.is_read_enough() {
852            return Err(WinxError::FileAccessError {
853                path: path.clone(),
854                message: format!(
855                    "Read more of the file before overwriting. Unread line ranges: {}",
856                    format_unread_ranges(whitelist)
857                ),
858            });
859        }
860    }
861
862    let result = if uses_search_replace {
863        let original_content = fs::read_to_string(&path)?;
864        let new_content = apply_blocks_with_unescape_retry(
865            &original_content,
866            &file_write_or_edit.text_or_search_replace_blocks,
867        )?;
868
869        write_no_follow(&path, new_content.as_bytes())?;
870        operation_result("edited", &file_path_str, &path, &new_content)
871    } else {
872        write_no_follow(&path, file_write_or_edit.text_or_search_replace_blocks.as_bytes())?;
873        operation_result(
874            "wrote",
875            &file_path_str,
876            &path,
877            &file_write_or_edit.text_or_search_replace_blocks,
878        )
879    };
880
881    // Update whitelist
882    let final_content = fs::read_to_string(&path)?;
883    let hash = hash_content(&final_content);
884    let total_lines = final_content.lines().count();
885
886    bash_state
887        .whitelist_for_overwrite
888        .insert(file_path_str, FileWhitelistData::new(hash, vec![(1, total_lines)], total_lines));
889    if uses_search_replace {
890        let _ = crate::utils::workspace_stats::record_edit(&bash_state.workspace_root, &path);
891    } else {
892        let _ = crate::utils::workspace_stats::record_write(&bash_state.workspace_root, &path);
893    }
894
895    Ok(result)
896}
897
898fn operation_result(action: &str, file_path: &str, path: &Path, content: &str) -> String {
899    let mut result = format!("Successfully {action} {file_path}");
900    if let Some(warning) = crate::utils::syntax::syntax_warning(path, content) {
901        let _ = write!(result, "\n\n{warning}");
902    }
903    result
904}
905
906#[cfg(test)]
907mod indentation_tests {
908    use super::*;
909
910    // The indent fixer used to byte-slice over indentation that can contain
911    // multibyte whitespace (ideographic space U+3000, NBSP) — a guaranteed
912    // panic. These pin the char-based behavior.
913
914    #[test]
915    fn fix_indentation_adds_multibyte_indent_without_panic() {
916        // matched has 2 ideographic spaces, searched has 1 -> diff = -1 (add 1).
917        let matched = vec!["\u{3000}\u{3000}x".to_string()];
918        let searched = vec!["\u{3000}x".to_string()];
919        let replaced = vec!["y".to_string()];
920        let out = fix_indentation(&matched, &searched, &replaced);
921        assert_eq!(out, vec!["\u{3000}y".to_string()]);
922    }
923
924    #[test]
925    fn fix_indentation_removes_multibyte_indent_without_panic() {
926        // matched has 1 ideographic space, searched has 2 -> diff = +1 (remove 1).
927        let matched = vec!["\u{3000}x".to_string()];
928        let searched = vec!["\u{3000}\u{3000}x".to_string()];
929        let replaced = vec!["\u{3000}y".to_string()];
930        let out = fix_indentation(&matched, &searched, &replaced);
931        assert_eq!(out, vec!["y".to_string()]);
932    }
933
934    #[test]
935    fn apply_blocks_preserves_crlf_endings() -> Result<()> {
936        // A CRLF file must round-trip as CRLF — not turn into mixed endings where
937        // the edited line is bare LF while untouched lines keep CRLF.
938        let content = "line one\r\nline two\r\nline three\r\n";
939        let block = SearchReplaceBlock {
940            search: vec!["line two".to_string()],
941            replace: vec!["line TWO".to_string()],
942        };
943        let out = apply_blocks(content, &[block])?;
944        assert_eq!(out, "line one\r\nline TWO\r\nline three\r\n");
945        Ok(())
946    }
947
948    #[test]
949    fn apply_blocks_leaves_lf_files_as_lf() -> Result<()> {
950        let content = "a\nb\nc\n";
951        let block =
952            SearchReplaceBlock { search: vec!["b".to_string()], replace: vec!["B".to_string()] };
953        let out = apply_blocks(content, &[block])?;
954        assert_eq!(out, "a\nB\nc\n");
955        assert!(!out.contains('\r'));
956        Ok(())
957    }
958}