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    let original_lines = split_lines(content);
188    let edited = apply_blocks_ordered(&original_lines, blocks).or_else(|ordered_error| {
189        if blocks.len() == 1 {
190            Err(ordered_error)
191        } else {
192            apply_blocks_individually(&original_lines, blocks)
193        }
194    })?;
195
196    Ok(edited.join("\n"))
197}
198
199fn split_lines(content: &str) -> Vec<String> {
200    content.split('\n').map(str::to_string).collect()
201}
202
203/// Write `content` to `path`, refusing to follow a symlink at the final path
204/// component.
205///
206/// `validate_path_in_workspace` canonicalizes the parent and confirms it is
207/// inside the workspace, but there is a TOCTOU window between that check and the
208/// write: anyone able to create files in the parent could drop a symlink in
209/// place of the target and redirect the write outside the workspace. `O_NOFOLLOW`
210/// closes that window — the open fails with `ELOOP` if the final component is a
211/// symlink. Non-Unix targets fall back to a plain write.
212fn write_no_follow(path: &Path, content: &[u8]) -> std::io::Result<()> {
213    #[cfg(unix)]
214    {
215        use std::os::unix::fs::OpenOptionsExt;
216        let mut file = fs::OpenOptions::new()
217            .write(true)
218            .create(true)
219            .truncate(true)
220            .custom_flags(libc::O_NOFOLLOW)
221            .open(path)?;
222        file.write_all(content)
223    }
224    #[cfg(not(unix))]
225    {
226        fs::write(path, content)
227    }
228}
229
230/// A single SEARCH block matching more than this many locations is rejected as
231/// too ambiguous, instead of fanning the matcher out exponentially.
232const MAX_CANDIDATES_PER_BLOCK: usize = 64;
233/// Global cap on nodes explored by the ordered matcher — a backstop against the
234/// O(C^B) blow-up when several blocks each match in many places.
235const MAX_SEARCH_NODES: u32 = 50_000;
236/// Refuse to apply an edit whose accumulated tolerance score exceeds this — the
237/// match was forced through too many fuzzy fixups to trust. Mirrors wcgw's
238/// `replace_or_throw` "Too many warnings, not applying" guard.
239const MAX_TOTAL_TOLERANCE_SCORE: usize = 1000;
240
241fn apply_blocks_ordered(lines: &[String], blocks: &[SearchReplaceBlock]) -> Result<Vec<String>> {
242    let mut budget = MAX_SEARCH_NODES;
243    let (score, replacements) = best_ordered_replacements(lines, blocks, 0, 0, &mut budget)?;
244    if score > MAX_TOTAL_TOLERANCE_SCORE {
245        return Err(WinxError::SearchBlockNotFound(format!(
246            "SEARCH blocks only matched very loosely (tolerance score {score} over limit \
247             {MAX_TOTAL_TOLERANCE_SCORE}). The file likely changed since you read it — re-read it \
248             and make the SEARCH text match the current content exactly."
249        )));
250    }
251    Ok(apply_replacements(lines, &replacements))
252}
253
254fn best_ordered_replacements(
255    lines: &[String],
256    blocks: &[SearchReplaceBlock],
257    block_index: usize,
258    offset: usize,
259    budget: &mut u32,
260) -> Result<(usize, Vec<Replacement>)> {
261    if block_index >= blocks.len() {
262        return Ok((0, Vec::new()));
263    }
264    if *budget == 0 {
265        return Err(WinxError::SearchBlockNotFound(
266            "Search/replace is too ambiguous (too many candidate combinations). Add more \
267             surrounding context so each SEARCH block matches a unique location."
268                .to_string(),
269        ));
270    }
271    *budget -= 1;
272
273    let block = &blocks[block_index];
274    let candidates = find_candidates(lines, block, offset);
275    if candidates.is_empty() {
276        return Err(not_found_error(block, lines, offset));
277    }
278    if candidates.len() > MAX_CANDIDATES_PER_BLOCK {
279        return Err(WinxError::SearchBlockNotFound(format!(
280            "A SEARCH block matches {} locations (limit {MAX_CANDIDATES_PER_BLOCK}); add more \
281             surrounding context to make it unique:\n{}",
282            candidates.len(),
283            block.search.join("\n")
284        )));
285    }
286
287    let mut valid_paths = Vec::new();
288    for candidate in candidates {
289        if let Ok((tail_score, mut tail)) =
290            best_ordered_replacements(lines, blocks, block_index + 1, candidate.end, budget)
291        {
292            let mut path = vec![Replacement {
293                start: candidate.start,
294                end: candidate.end,
295                replace: candidate.replace,
296            }];
297            path.append(&mut tail);
298            valid_paths.push((candidate.score + tail_score, path));
299        }
300    }
301
302    select_unique_best_path(block, valid_paths)
303}
304
305fn select_unique_best_path(
306    block: &SearchReplaceBlock,
307    paths: Vec<(usize, Vec<Replacement>)>,
308) -> Result<(usize, Vec<Replacement>)> {
309    let Some(best_score) = paths.iter().map(|(score, _)| *score).min() else {
310        return Err(WinxError::SearchBlockNotFound(format!(
311            "Block not found: {}",
312            block.search.join("\n")
313        )));
314    };
315
316    let best_paths: Vec<(usize, Vec<Replacement>)> =
317        paths.into_iter().filter(|(score, _)| *score == best_score).collect();
318
319    if best_paths.len() == 1 {
320        return best_paths.into_iter().next().ok_or_else(|| {
321            WinxError::SearchBlockNotFound(format!("Block not found: {}", block.search.join("\n")))
322        });
323    }
324
325    Err(WinxError::SearchBlockAmbiguous {
326        block_content: block.search.join("\n"),
327        match_count: best_paths.len(),
328        suggestions: vec!["Add more context before or after this block.".to_string()],
329    })
330}
331
332fn apply_blocks_individually(
333    lines: &[String],
334    blocks: &[SearchReplaceBlock],
335) -> Result<Vec<String>> {
336    let mut running_lines = lines.to_vec();
337    for block in blocks {
338        let candidate = select_unique_candidate(block, find_candidates(&running_lines, block, 0))?;
339        running_lines = apply_replacements(
340            &running_lines,
341            &[Replacement {
342                start: candidate.start,
343                end: candidate.end,
344                replace: candidate.replace,
345            }],
346        );
347    }
348    Ok(running_lines)
349}
350
351fn select_unique_candidate(
352    block: &SearchReplaceBlock,
353    candidates: Vec<MatchCandidate>,
354) -> Result<MatchCandidate> {
355    if candidates.is_empty() {
356        return Err(WinxError::SearchBlockNotFound(format!(
357            "Block not found: {}",
358            block.search.join("\n")
359        )));
360    }
361
362    let best_score = candidates.iter().map(|candidate| candidate.score).min().unwrap_or(0);
363    let best: Vec<MatchCandidate> =
364        candidates.into_iter().filter(|candidate| candidate.score == best_score).collect();
365
366    if best.len() == 1 {
367        return best.into_iter().next().ok_or_else(|| {
368            WinxError::SearchBlockNotFound(format!("Block not found: {}", block.search.join("\n")))
369        });
370    }
371
372    Err(WinxError::SearchBlockAmbiguous {
373        block_content: block.search.join("\n"),
374        match_count: best.len(),
375        suggestions: vec!["Add more context to make the search block unique.".to_string()],
376    })
377}
378
379fn apply_replacements(lines: &[String], replacements: &[Replacement]) -> Vec<String> {
380    let mut edited = Vec::new();
381    let mut cursor = 0;
382
383    for replacement in replacements {
384        edited.extend_from_slice(&lines[cursor..replacement.start]);
385        edited.extend(replacement.replace.clone());
386        cursor = replacement.end;
387    }
388
389    edited.extend_from_slice(&lines[cursor..]);
390    edited
391}
392
393fn find_candidates(
394    lines: &[String],
395    block: &SearchReplaceBlock,
396    offset: usize,
397) -> Vec<MatchCandidate> {
398    let mut candidates = find_contiguous_candidates(lines, block, offset, false);
399    if candidates.is_empty() {
400        candidates = find_single_line_substring_candidates(lines, block, offset);
401    }
402    if candidates.is_empty() {
403        candidates = find_contiguous_candidates(lines, block, offset, true);
404    }
405    candidates
406}
407
408fn find_single_line_substring_candidates(
409    lines: &[String],
410    block: &SearchReplaceBlock,
411    offset: usize,
412) -> Vec<MatchCandidate> {
413    if block.search.len() != 1 {
414        return Vec::new();
415    }
416
417    let search = &block.search[0];
418    if search.is_empty() {
419        return Vec::new();
420    }
421
422    let replace = block.replace.join("\n");
423    lines
424        .iter()
425        .enumerate()
426        .skip(offset)
427        .flat_map(|(index, line)| {
428            let replace = replace.clone();
429            line.match_indices(search).map(move |(byte_index, _)| {
430                let mut replaced_line = line.clone();
431                replaced_line.replace_range(byte_index..byte_index + search.len(), &replace);
432                MatchCandidate {
433                    start: index,
434                    end: index + 1,
435                    score: 0,
436                    tolerances: Vec::new(),
437                    replace: split_lines(&replaced_line),
438                }
439            })
440        })
441        .collect()
442}
443
444fn find_contiguous_candidates(
445    lines: &[String],
446    block: &SearchReplaceBlock,
447    offset: usize,
448    ignore_empty_lines: bool,
449) -> Vec<MatchCandidate> {
450    let search_lines = if ignore_empty_lines {
451        block.search.iter().filter(|line| !line.trim().is_empty()).cloned().collect()
452    } else {
453        block.search.clone()
454    };
455
456    if search_lines.is_empty() || lines.len().saturating_sub(offset) < search_lines.len() {
457        return Vec::new();
458    }
459
460    if ignore_empty_lines {
461        return find_empty_line_tolerant_candidates(lines, block, offset, &search_lines);
462    }
463
464    let max_start = lines.len() - search_lines.len();
465    (offset..=max_start)
466        .filter_map(|start| {
467            let end = start + search_lines.len();
468            let actual_lines: Vec<&String> = lines[start..end].iter().collect();
469            match_candidate(lines, &actual_lines, &search_lines, block, start, end, false)
470        })
471        .collect()
472}
473
474fn find_empty_line_tolerant_candidates(
475    lines: &[String],
476    block: &SearchReplaceBlock,
477    offset: usize,
478    search_lines: &[String],
479) -> Vec<MatchCandidate> {
480    let compact_lines: Vec<(usize, &String)> =
481        lines.iter().enumerate().skip(offset).filter(|(_, line)| !line.trim().is_empty()).collect();
482
483    if compact_lines.len() < search_lines.len() {
484        return Vec::new();
485    }
486
487    let max_start = compact_lines.len() - search_lines.len();
488    (0..=max_start)
489        .filter_map(|compact_start| {
490            let compact_end = compact_start + search_lines.len();
491            let start = compact_lines[compact_start].0;
492            let end = compact_lines[compact_end - 1].0 + 1;
493            let actual_lines: Vec<&String> =
494                compact_lines[compact_start..compact_end].iter().map(|(_, line)| *line).collect();
495            match_candidate(lines, &actual_lines, search_lines, block, start, end, true)
496        })
497        .collect()
498}
499
500fn match_candidate(
501    lines: &[String],
502    actual_lines: &[&String],
503    search_lines: &[String],
504    block: &SearchReplaceBlock,
505    start: usize,
506    end: usize,
507    ignore_empty_lines: bool,
508) -> Option<MatchCandidate> {
509    let mut tolerances = Vec::new();
510    let mut score = 0;
511
512    for (actual, expected) in actual_lines.iter().zip(search_lines) {
513        let line_match = matching_tolerance(actual, expected)?;
514        if let LineMatch::Tolerated(tolerance) = line_match {
515            score += tolerance.score();
516            if !tolerances.contains(&tolerance) {
517                tolerances.push(tolerance);
518            }
519        }
520    }
521
522    let mut replace = if ignore_empty_lines {
523        trim_empty_edge_lines(&block.replace)
524    } else {
525        block.replace.clone()
526    };
527    if tolerances.contains(&ToleranceKind::RemoveLineNumbers) {
528        replace = replace.into_iter().map(|line| remove_leading_line_number(&line)).collect();
529    }
530    if tolerances.contains(&ToleranceKind::IgnoreIndentation) {
531        let matched = &lines[start..end];
532        replace = fix_indentation(matched, search_lines, &replace);
533    }
534
535    Some(MatchCandidate { start, end, score, tolerances, replace })
536}
537
538fn matching_tolerance(actual: &str, expected: &str) -> Option<LineMatch> {
539    if actual == expected {
540        return Some(LineMatch::Exact);
541    }
542    if actual.trim_end() == expected.trim_end() {
543        return Some(LineMatch::Tolerated(ToleranceKind::TrimEnd));
544    }
545    if actual.trim_start() == expected.trim_start() {
546        return Some(LineMatch::Tolerated(ToleranceKind::IgnoreIndentation));
547    }
548    if remove_leading_line_number(actual) == remove_leading_line_number(expected) {
549        return Some(LineMatch::Tolerated(ToleranceKind::RemoveLineNumbers));
550    }
551    if normalize_common_mistakes(actual) == normalize_common_mistakes(expected) {
552        return Some(LineMatch::Tolerated(ToleranceKind::NormalizeCommonMistakes));
553    }
554    if remove_ascii_whitespace(actual) == remove_ascii_whitespace(expected) {
555        return Some(LineMatch::Tolerated(ToleranceKind::IgnoreWhitespace));
556    }
557    None
558}
559
560fn remove_ascii_whitespace(value: &str) -> String {
561    value.chars().filter(|c| !c.is_whitespace()).collect()
562}
563
564fn remove_leading_line_number(value: &str) -> String {
565    value
566        .split_once(' ')
567        .filter(|(prefix, _)| !prefix.is_empty() && prefix.chars().all(|c| c.is_ascii_digit()))
568        .map_or_else(|| value.trim_end().to_string(), |(_, rest)| rest.trim_end().to_string())
569}
570
571fn normalize_common_mistakes(value: &str) -> String {
572    let mut normalized = String::with_capacity(value.len());
573    for character in value.chars() {
574        match character {
575            '\u{2018}' | '\u{2019}' | '\u{201b}' | '\u{2032}' => normalized.push('\''),
576            '\u{201a}' => normalized.push(','),
577            '\u{201c}' | '\u{201d}' | '\u{201f}' | '\u{2033}' => normalized.push('"'),
578            '\u{2039}' => normalized.push('<'),
579            '\u{203a}' => normalized.push('>'),
580            '\u{2010}' | '\u{2011}' | '\u{2012}' | '\u{2013}' | '\u{2014}' | '\u{2015}'
581            | '\u{2212}' => normalized.push('-'),
582            '\u{2026}' => normalized.push_str("..."),
583            other => normalized.push(other),
584        }
585    }
586    normalized.trim_end().to_string()
587}
588
589fn fix_indentation(
590    matched_lines: &[String],
591    searched_lines: &[String],
592    replaced_lines: &[String],
593) -> Vec<String> {
594    if matched_lines.is_empty() || searched_lines.is_empty() || replaced_lines.is_empty() {
595        return replaced_lines.to_vec();
596    }
597
598    let matched_indents = non_empty_indents(matched_lines);
599    let searched_indents = non_empty_indents(searched_lines);
600    if matched_indents.len() != searched_indents.len() || matched_indents.is_empty() {
601        return replaced_lines.to_vec();
602    }
603
604    let diffs: Vec<isize> = matched_indents
605        .iter()
606        .zip(&searched_indents)
607        .map(|(matched, searched)| searched.len() as isize - matched.len() as isize)
608        .collect();
609    let first_diff = diffs[0];
610    if first_diff == 0 || !diffs.iter().all(|diff| *diff == first_diff) {
611        return replaced_lines.to_vec();
612    }
613
614    adjust_replacement_indentation(replaced_lines, &matched_indents[0], first_diff)
615}
616
617fn non_empty_indents(lines: &[String]) -> Vec<String> {
618    lines
619        .iter()
620        .filter(|line| !line.trim().is_empty())
621        .map(|line| line.chars().take_while(|c| c.is_whitespace()).collect())
622        .collect()
623}
624
625fn adjust_replacement_indentation(
626    replaced_lines: &[String],
627    matched_indent: &str,
628    diff: isize,
629) -> Vec<String> {
630    if diff < 0 {
631        let prefix_len = usize::try_from(-diff).unwrap_or(0).min(matched_indent.len());
632        let prefix = &matched_indent[..prefix_len];
633        return replaced_lines.iter().map(|line| format!("{prefix}{line}")).collect();
634    }
635
636    let remove_len = usize::try_from(diff).unwrap_or(0);
637    if !replaced_lines.iter().all(|line| removable_indent(line, remove_len)) {
638        return replaced_lines.to_vec();
639    }
640    replaced_lines.iter().map(|line| line[remove_len..].to_string()).collect()
641}
642
643fn removable_indent(line: &str, remove_len: usize) -> bool {
644    line.len() >= remove_len && line[..remove_len].chars().all(char::is_whitespace)
645}
646
647fn trim_empty_edge_lines(lines: &[String]) -> Vec<String> {
648    let Some(first) = lines.iter().position(|line| !line.trim().is_empty()) else {
649        return Vec::new();
650    };
651    let last = lines.iter().rposition(|line| !line.trim().is_empty()).unwrap_or(first);
652    lines[first..=last].to_vec()
653}
654
655/// Lines of surrounding context shown around the closest match (wcgw parity:
656/// `find_least_edit_distance_substring` returns the match ± 10 lines).
657const SNIPPET_CONTEXT_LINES: usize = 10;
658
659fn not_found_error(block: &SearchReplaceBlock, lines: &[String], offset: usize) -> WinxError {
660    let snippet = closest_snippet(lines, offset, &block.search);
661    WinxError::SearchBlockNotFound(format!(
662        "Block not found in file. The SEARCH block below didn't match anywhere:\n{}\n\n\
663         Closest matching context in the file (with surrounding lines):\n{}",
664        block.search.join("\n"),
665        snippet
666    ))
667}
668
669fn closest_snippet(lines: &[String], offset: usize, search: &[String]) -> String {
670    let window = search.len().max(1);
671    if lines.is_empty() || offset >= lines.len() {
672        return String::new();
673    }
674
675    let max_start = lines.len().saturating_sub(window);
676    let mut best_start = offset;
677    let mut best_score = f64::MIN;
678    for start in offset..=max_start {
679        let score = snippet_similarity(&lines[start..(start + window)], search);
680        if score > best_score {
681            best_score = score;
682            best_start = start;
683        }
684    }
685
686    // Widen to ±10 lines around the best match so the model can locate it, with
687    // 1-based line numbers (the file is shown numbered elsewhere too).
688    let context_start = best_start.saturating_sub(SNIPPET_CONTEXT_LINES);
689    let context_end = (best_start + window + SNIPPET_CONTEXT_LINES).min(lines.len());
690    lines[context_start..context_end]
691        .iter()
692        .enumerate()
693        .map(|(index, line)| format!("{:>6}  {}", context_start + index + 1, line))
694        .collect::<Vec<_>>()
695        .join("\n")
696}
697
698fn snippet_similarity(candidate: &[String], search: &[String]) -> f64 {
699    candidate
700        .iter()
701        .zip(search)
702        .map(|(candidate_line, search_line)| {
703            strsim::normalized_levenshtein(candidate_line.trim(), search_line.trim())
704        })
705        .sum::<f64>()
706        - candidate.len().abs_diff(search.len()) as f64
707}
708
709fn uses_search_replace(file_write_or_edit: &FileWriteOrEdit) -> bool {
710    if file_write_or_edit.percentage_to_change <= 50 {
711        return true;
712    }
713
714    let first_content_line =
715        file_write_or_edit.text_or_search_replace_blocks.trim_start().lines().next();
716    first_content_line.is_some_and(|line| search_marker().is_ok_and(|marker| marker.is_match(line)))
717}
718
719fn hash_content(content: &str) -> String {
720    let digest = Sha256::digest(content.as_bytes());
721    digest.iter().fold(String::with_capacity(digest.len() * 2), |mut hash, byte| {
722        let _ = write!(hash, "{byte:02x}");
723        hash
724    })
725}
726
727fn format_unread_ranges(whitelist: &FileWhitelistData) -> String {
728    whitelist
729        .get_unread_ranges()
730        .into_iter()
731        .map(|(start, end)| if start == end { start.to_string() } else { format!("{start}-{end}") })
732        .collect::<Vec<_>>()
733        .join(", ")
734}
735
736#[instrument(level = "info", skip(bash_state_arc, file_write_or_edit))]
737pub async fn handle_tool_call(
738    bash_state_arc: &Arc<Mutex<Option<BashState>>>,
739    file_write_or_edit: FileWriteOrEdit,
740) -> Result<String> {
741    let mut bash_state_guard = bash_state_arc.lock().await;
742    let bash_state = bash_state_guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
743
744    let thread_id = normalize_thread_id(&file_write_or_edit.thread_id);
745    if thread_id != bash_state.current_thread_id {
746        return Err(WinxError::ThreadIdMismatch(thread_id));
747    }
748
749    let expanded_path = expand_user(&file_write_or_edit.file_path);
750    let path = if Path::new(&expanded_path).is_absolute() {
751        PathBuf::from(&expanded_path)
752    } else {
753        bash_state.cwd.join(&expanded_path)
754    };
755
756    let path = validate_path_in_workspace(&path, &bash_state.workspace_root)
757        .map_err(|e| WinxError::PathSecurityError { path: path.clone(), message: e.to_string() })?;
758
759    let file_path_str = path.to_string_lossy().to_string();
760
761    let uses_search_replace = uses_search_replace(&file_write_or_edit);
762    let operation_allowed = if uses_search_replace {
763        bash_state.is_file_edit_allowed(&file_path_str)
764    } else {
765        bash_state.is_file_write_allowed(&file_path_str)
766    };
767
768    if !operation_allowed {
769        return Err(WinxError::FileAccessError {
770            path: path.clone(),
771            message: "File operation not allowed in current mode.".to_string(),
772        });
773    }
774
775    if path.exists() {
776        let whitelist =
777            bash_state.whitelist_for_overwrite.get(&file_path_str).ok_or_else(|| {
778                WinxError::FileAccessError {
779                    path: path.clone(),
780                    message: format!(
781                        "This file exists but hasn't been read in this session. \
782                         Call ReadFiles on {file_path_str} first, then retry the edit \
783                         (winx requires a fresh read so edits are never applied blind)."
784                    ),
785                }
786            })?;
787        let original_content = fs::read_to_string(&path)?;
788        let current_hash = hash_content(&original_content);
789        if whitelist.file_hash != current_hash {
790            return Err(WinxError::FileAccessError {
791                path: path.clone(),
792                message: format!(
793                    "{file_path_str} changed on disk since you last read it. \
794                     Call ReadFiles again to get the current content, then retry the edit."
795                ),
796            });
797        }
798        if !uses_search_replace && !whitelist.is_read_enough() {
799            return Err(WinxError::FileAccessError {
800                path: path.clone(),
801                message: format!(
802                    "Read more of the file before overwriting. Unread line ranges: {}",
803                    format_unread_ranges(whitelist)
804                ),
805            });
806        }
807    }
808
809    let result = if uses_search_replace {
810        let original_content = fs::read_to_string(&path)?;
811        let new_content = apply_blocks_with_unescape_retry(
812            &original_content,
813            &file_write_or_edit.text_or_search_replace_blocks,
814        )?;
815
816        write_no_follow(&path, new_content.as_bytes())?;
817        operation_result("edited", &file_path_str, &path, &new_content)
818    } else {
819        write_no_follow(&path, file_write_or_edit.text_or_search_replace_blocks.as_bytes())?;
820        operation_result(
821            "wrote",
822            &file_path_str,
823            &path,
824            &file_write_or_edit.text_or_search_replace_blocks,
825        )
826    };
827
828    // Update whitelist
829    let final_content = fs::read_to_string(&path)?;
830    let hash = hash_content(&final_content);
831    let total_lines = final_content.lines().count();
832
833    bash_state
834        .whitelist_for_overwrite
835        .insert(file_path_str, FileWhitelistData::new(hash, vec![(1, total_lines)], total_lines));
836    if uses_search_replace {
837        let _ = crate::utils::workspace_stats::record_edit(&bash_state.workspace_root, &path);
838    } else {
839        let _ = crate::utils::workspace_stats::record_write(&bash_state.workspace_root, &path);
840    }
841
842    Ok(result)
843}
844
845fn operation_result(action: &str, file_path: &str, path: &Path, content: &str) -> String {
846    let mut result = format!("Successfully {action} {file_path}");
847    if let Some(warning) = crate::utils::syntax::syntax_warning(path, content) {
848        let _ = write!(result, "\n\n{warning}");
849    }
850    result
851}