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
168fn apply_blocks(content: &str, blocks: &[SearchReplaceBlock]) -> Result<String> {
169    let original_lines = split_lines(content);
170    let edited = apply_blocks_ordered(&original_lines, blocks).or_else(|ordered_error| {
171        if blocks.len() == 1 {
172            Err(ordered_error)
173        } else {
174            apply_blocks_individually(&original_lines, blocks)
175        }
176    })?;
177
178    Ok(edited.join("\n"))
179}
180
181fn split_lines(content: &str) -> Vec<String> {
182    content.split('\n').map(str::to_string).collect()
183}
184
185fn apply_blocks_ordered(lines: &[String], blocks: &[SearchReplaceBlock]) -> Result<Vec<String>> {
186    let (_, replacements) = best_ordered_replacements(lines, blocks, 0, 0)?;
187    Ok(apply_replacements(lines, &replacements))
188}
189
190fn best_ordered_replacements(
191    lines: &[String],
192    blocks: &[SearchReplaceBlock],
193    block_index: usize,
194    offset: usize,
195) -> Result<(usize, Vec<Replacement>)> {
196    if block_index >= blocks.len() {
197        return Ok((0, Vec::new()));
198    }
199
200    let block = &blocks[block_index];
201    let candidates = find_candidates(lines, block, offset);
202    if candidates.is_empty() {
203        return Err(not_found_error(block, lines, offset));
204    }
205
206    let mut valid_paths = Vec::new();
207    for candidate in candidates {
208        if let Ok((tail_score, mut tail)) =
209            best_ordered_replacements(lines, blocks, block_index + 1, candidate.end)
210        {
211            let mut path = vec![Replacement {
212                start: candidate.start,
213                end: candidate.end,
214                replace: candidate.replace,
215            }];
216            path.append(&mut tail);
217            valid_paths.push((candidate.score + tail_score, path));
218        }
219    }
220
221    select_unique_best_path(block, valid_paths)
222}
223
224fn select_unique_best_path(
225    block: &SearchReplaceBlock,
226    paths: Vec<(usize, Vec<Replacement>)>,
227) -> Result<(usize, Vec<Replacement>)> {
228    let Some(best_score) = paths.iter().map(|(score, _)| *score).min() else {
229        return Err(WinxError::SearchBlockNotFound(format!(
230            "Block not found: {}",
231            block.search.join("\n")
232        )));
233    };
234
235    let best_paths: Vec<(usize, Vec<Replacement>)> =
236        paths.into_iter().filter(|(score, _)| *score == best_score).collect();
237
238    if best_paths.len() == 1 {
239        return best_paths.into_iter().next().ok_or_else(|| {
240            WinxError::SearchBlockNotFound(format!("Block not found: {}", block.search.join("\n")))
241        });
242    }
243
244    Err(WinxError::SearchBlockAmbiguous {
245        block_content: block.search.join("\n"),
246        match_count: best_paths.len(),
247        suggestions: vec!["Add more context before or after this block.".to_string()],
248    })
249}
250
251fn apply_blocks_individually(
252    lines: &[String],
253    blocks: &[SearchReplaceBlock],
254) -> Result<Vec<String>> {
255    let mut running_lines = lines.to_vec();
256    for block in blocks {
257        let candidate = select_unique_candidate(block, find_candidates(&running_lines, block, 0))?;
258        running_lines = apply_replacements(
259            &running_lines,
260            &[Replacement {
261                start: candidate.start,
262                end: candidate.end,
263                replace: candidate.replace,
264            }],
265        );
266    }
267    Ok(running_lines)
268}
269
270fn select_unique_candidate(
271    block: &SearchReplaceBlock,
272    candidates: Vec<MatchCandidate>,
273) -> Result<MatchCandidate> {
274    if candidates.is_empty() {
275        return Err(WinxError::SearchBlockNotFound(format!(
276            "Block not found: {}",
277            block.search.join("\n")
278        )));
279    }
280
281    let best_score = candidates.iter().map(|candidate| candidate.score).min().unwrap_or(0);
282    let best: Vec<MatchCandidate> =
283        candidates.into_iter().filter(|candidate| candidate.score == best_score).collect();
284
285    if best.len() == 1 {
286        return best.into_iter().next().ok_or_else(|| {
287            WinxError::SearchBlockNotFound(format!("Block not found: {}", block.search.join("\n")))
288        });
289    }
290
291    Err(WinxError::SearchBlockAmbiguous {
292        block_content: block.search.join("\n"),
293        match_count: best.len(),
294        suggestions: vec!["Add more context to make the search block unique.".to_string()],
295    })
296}
297
298fn apply_replacements(lines: &[String], replacements: &[Replacement]) -> Vec<String> {
299    let mut edited = Vec::new();
300    let mut cursor = 0;
301
302    for replacement in replacements {
303        edited.extend_from_slice(&lines[cursor..replacement.start]);
304        edited.extend(replacement.replace.clone());
305        cursor = replacement.end;
306    }
307
308    edited.extend_from_slice(&lines[cursor..]);
309    edited
310}
311
312fn find_candidates(
313    lines: &[String],
314    block: &SearchReplaceBlock,
315    offset: usize,
316) -> Vec<MatchCandidate> {
317    let mut candidates = find_contiguous_candidates(lines, block, offset, false);
318    if candidates.is_empty() {
319        candidates = find_single_line_substring_candidates(lines, block, offset);
320    }
321    if candidates.is_empty() {
322        candidates = find_contiguous_candidates(lines, block, offset, true);
323    }
324    candidates
325}
326
327fn find_single_line_substring_candidates(
328    lines: &[String],
329    block: &SearchReplaceBlock,
330    offset: usize,
331) -> Vec<MatchCandidate> {
332    if block.search.len() != 1 {
333        return Vec::new();
334    }
335
336    let search = &block.search[0];
337    if search.is_empty() {
338        return Vec::new();
339    }
340
341    let replace = block.replace.join("\n");
342    lines
343        .iter()
344        .enumerate()
345        .skip(offset)
346        .flat_map(|(index, line)| {
347            let replace = replace.clone();
348            line.match_indices(search).map(move |(byte_index, _)| {
349                let mut replaced_line = line.clone();
350                replaced_line.replace_range(byte_index..byte_index + search.len(), &replace);
351                MatchCandidate {
352                    start: index,
353                    end: index + 1,
354                    score: 0,
355                    tolerances: Vec::new(),
356                    replace: split_lines(&replaced_line),
357                }
358            })
359        })
360        .collect()
361}
362
363fn find_contiguous_candidates(
364    lines: &[String],
365    block: &SearchReplaceBlock,
366    offset: usize,
367    ignore_empty_lines: bool,
368) -> Vec<MatchCandidate> {
369    let search_lines = if ignore_empty_lines {
370        block.search.iter().filter(|line| !line.trim().is_empty()).cloned().collect()
371    } else {
372        block.search.clone()
373    };
374
375    if search_lines.is_empty() || lines.len().saturating_sub(offset) < search_lines.len() {
376        return Vec::new();
377    }
378
379    if ignore_empty_lines {
380        return find_empty_line_tolerant_candidates(lines, block, offset, &search_lines);
381    }
382
383    let max_start = lines.len() - search_lines.len();
384    (offset..=max_start)
385        .filter_map(|start| {
386            let end = start + search_lines.len();
387            let actual_lines: Vec<&String> = lines[start..end].iter().collect();
388            match_candidate(lines, &actual_lines, &search_lines, block, start, end, false)
389        })
390        .collect()
391}
392
393fn find_empty_line_tolerant_candidates(
394    lines: &[String],
395    block: &SearchReplaceBlock,
396    offset: usize,
397    search_lines: &[String],
398) -> Vec<MatchCandidate> {
399    let compact_lines: Vec<(usize, &String)> =
400        lines.iter().enumerate().skip(offset).filter(|(_, line)| !line.trim().is_empty()).collect();
401
402    if compact_lines.len() < search_lines.len() {
403        return Vec::new();
404    }
405
406    let max_start = compact_lines.len() - search_lines.len();
407    (0..=max_start)
408        .filter_map(|compact_start| {
409            let compact_end = compact_start + search_lines.len();
410            let start = compact_lines[compact_start].0;
411            let end = compact_lines[compact_end - 1].0 + 1;
412            let actual_lines: Vec<&String> =
413                compact_lines[compact_start..compact_end].iter().map(|(_, line)| *line).collect();
414            match_candidate(lines, &actual_lines, search_lines, block, start, end, true)
415        })
416        .collect()
417}
418
419fn match_candidate(
420    lines: &[String],
421    actual_lines: &[&String],
422    search_lines: &[String],
423    block: &SearchReplaceBlock,
424    start: usize,
425    end: usize,
426    ignore_empty_lines: bool,
427) -> Option<MatchCandidate> {
428    let mut tolerances = Vec::new();
429    let mut score = 0;
430
431    for (actual, expected) in actual_lines.iter().zip(search_lines) {
432        let line_match = matching_tolerance(actual, expected)?;
433        if let LineMatch::Tolerated(tolerance) = line_match {
434            score += tolerance.score();
435            if !tolerances.contains(&tolerance) {
436                tolerances.push(tolerance);
437            }
438        }
439    }
440
441    let mut replace = if ignore_empty_lines {
442        trim_empty_edge_lines(&block.replace)
443    } else {
444        block.replace.clone()
445    };
446    if tolerances.contains(&ToleranceKind::RemoveLineNumbers) {
447        replace = replace.into_iter().map(|line| remove_leading_line_number(&line)).collect();
448    }
449    if tolerances.contains(&ToleranceKind::IgnoreIndentation) {
450        let matched = &lines[start..end];
451        replace = fix_indentation(matched, search_lines, &replace);
452    }
453
454    Some(MatchCandidate { start, end, score, tolerances, replace })
455}
456
457fn matching_tolerance(actual: &str, expected: &str) -> Option<LineMatch> {
458    if actual == expected {
459        return Some(LineMatch::Exact);
460    }
461    if actual.trim_end() == expected.trim_end() {
462        return Some(LineMatch::Tolerated(ToleranceKind::TrimEnd));
463    }
464    if actual.trim_start() == expected.trim_start() {
465        return Some(LineMatch::Tolerated(ToleranceKind::IgnoreIndentation));
466    }
467    if remove_leading_line_number(actual) == remove_leading_line_number(expected) {
468        return Some(LineMatch::Tolerated(ToleranceKind::RemoveLineNumbers));
469    }
470    if normalize_common_mistakes(actual) == normalize_common_mistakes(expected) {
471        return Some(LineMatch::Tolerated(ToleranceKind::NormalizeCommonMistakes));
472    }
473    if remove_ascii_whitespace(actual) == remove_ascii_whitespace(expected) {
474        return Some(LineMatch::Tolerated(ToleranceKind::IgnoreWhitespace));
475    }
476    None
477}
478
479fn remove_ascii_whitespace(value: &str) -> String {
480    value.chars().filter(|c| !c.is_whitespace()).collect()
481}
482
483fn remove_leading_line_number(value: &str) -> String {
484    value
485        .split_once(' ')
486        .filter(|(prefix, _)| !prefix.is_empty() && prefix.chars().all(|c| c.is_ascii_digit()))
487        .map_or_else(|| value.trim_end().to_string(), |(_, rest)| rest.trim_end().to_string())
488}
489
490fn normalize_common_mistakes(value: &str) -> String {
491    let mut normalized = String::with_capacity(value.len());
492    for character in value.chars() {
493        match character {
494            '\u{2018}' | '\u{2019}' | '\u{201b}' | '\u{2032}' => normalized.push('\''),
495            '\u{201a}' => normalized.push(','),
496            '\u{201c}' | '\u{201d}' | '\u{201f}' | '\u{2033}' => normalized.push('"'),
497            '\u{2039}' => normalized.push('<'),
498            '\u{203a}' => normalized.push('>'),
499            '\u{2010}' | '\u{2011}' | '\u{2012}' | '\u{2013}' | '\u{2014}' | '\u{2015}'
500            | '\u{2212}' => normalized.push('-'),
501            '\u{2026}' => normalized.push_str("..."),
502            other => normalized.push(other),
503        }
504    }
505    normalized.trim_end().to_string()
506}
507
508fn fix_indentation(
509    matched_lines: &[String],
510    searched_lines: &[String],
511    replaced_lines: &[String],
512) -> Vec<String> {
513    if matched_lines.is_empty() || searched_lines.is_empty() || replaced_lines.is_empty() {
514        return replaced_lines.to_vec();
515    }
516
517    let matched_indents = non_empty_indents(matched_lines);
518    let searched_indents = non_empty_indents(searched_lines);
519    if matched_indents.len() != searched_indents.len() || matched_indents.is_empty() {
520        return replaced_lines.to_vec();
521    }
522
523    let diffs: Vec<isize> = matched_indents
524        .iter()
525        .zip(&searched_indents)
526        .map(|(matched, searched)| searched.len() as isize - matched.len() as isize)
527        .collect();
528    let first_diff = diffs[0];
529    if first_diff == 0 || !diffs.iter().all(|diff| *diff == first_diff) {
530        return replaced_lines.to_vec();
531    }
532
533    adjust_replacement_indentation(replaced_lines, &matched_indents[0], first_diff)
534}
535
536fn non_empty_indents(lines: &[String]) -> Vec<String> {
537    lines
538        .iter()
539        .filter(|line| !line.trim().is_empty())
540        .map(|line| line.chars().take_while(|c| c.is_whitespace()).collect())
541        .collect()
542}
543
544fn adjust_replacement_indentation(
545    replaced_lines: &[String],
546    matched_indent: &str,
547    diff: isize,
548) -> Vec<String> {
549    if diff < 0 {
550        let prefix_len = usize::try_from(-diff).unwrap_or(0).min(matched_indent.len());
551        let prefix = &matched_indent[..prefix_len];
552        return replaced_lines.iter().map(|line| format!("{prefix}{line}")).collect();
553    }
554
555    let remove_len = usize::try_from(diff).unwrap_or(0);
556    if !replaced_lines.iter().all(|line| removable_indent(line, remove_len)) {
557        return replaced_lines.to_vec();
558    }
559    replaced_lines.iter().map(|line| line[remove_len..].to_string()).collect()
560}
561
562fn removable_indent(line: &str, remove_len: usize) -> bool {
563    line.len() >= remove_len && line[..remove_len].chars().all(char::is_whitespace)
564}
565
566fn trim_empty_edge_lines(lines: &[String]) -> Vec<String> {
567    let Some(first) = lines.iter().position(|line| !line.trim().is_empty()) else {
568        return Vec::new();
569    };
570    let last = lines.iter().rposition(|line| !line.trim().is_empty()).unwrap_or(first);
571    lines[first..=last].to_vec()
572}
573
574/// Lines of surrounding context shown around the closest match (wcgw parity:
575/// `find_least_edit_distance_substring` returns the match ± 10 lines).
576const SNIPPET_CONTEXT_LINES: usize = 10;
577
578fn not_found_error(block: &SearchReplaceBlock, lines: &[String], offset: usize) -> WinxError {
579    let snippet = closest_snippet(lines, offset, &block.search);
580    WinxError::SearchBlockNotFound(format!(
581        "Block not found in file. The SEARCH block below didn't match anywhere:\n{}\n\n\
582         Closest matching context in the file (with surrounding lines):\n{}",
583        block.search.join("\n"),
584        snippet
585    ))
586}
587
588fn closest_snippet(lines: &[String], offset: usize, search: &[String]) -> String {
589    let window = search.len().max(1);
590    if lines.is_empty() || offset >= lines.len() {
591        return String::new();
592    }
593
594    let max_start = lines.len().saturating_sub(window);
595    let mut best_start = offset;
596    let mut best_score = f64::MIN;
597    for start in offset..=max_start {
598        let score = snippet_similarity(&lines[start..(start + window)], search);
599        if score > best_score {
600            best_score = score;
601            best_start = start;
602        }
603    }
604
605    // Widen to ±10 lines around the best match so the model can locate it, with
606    // 1-based line numbers (the file is shown numbered elsewhere too).
607    let context_start = best_start.saturating_sub(SNIPPET_CONTEXT_LINES);
608    let context_end = (best_start + window + SNIPPET_CONTEXT_LINES).min(lines.len());
609    lines[context_start..context_end]
610        .iter()
611        .enumerate()
612        .map(|(index, line)| format!("{:>6}  {}", context_start + index + 1, line))
613        .collect::<Vec<_>>()
614        .join("\n")
615}
616
617fn snippet_similarity(candidate: &[String], search: &[String]) -> f64 {
618    candidate
619        .iter()
620        .zip(search)
621        .map(|(candidate_line, search_line)| {
622            strsim::normalized_levenshtein(candidate_line.trim(), search_line.trim())
623        })
624        .sum::<f64>()
625        - candidate.len().abs_diff(search.len()) as f64
626}
627
628fn uses_search_replace(file_write_or_edit: &FileWriteOrEdit) -> bool {
629    if file_write_or_edit.percentage_to_change <= 50 {
630        return true;
631    }
632
633    let first_content_line =
634        file_write_or_edit.text_or_search_replace_blocks.trim_start().lines().next();
635    first_content_line.is_some_and(|line| search_marker().is_ok_and(|marker| marker.is_match(line)))
636}
637
638fn hash_content(content: &str) -> String {
639    let digest = Sha256::digest(content.as_bytes());
640    digest.iter().fold(String::with_capacity(digest.len() * 2), |mut hash, byte| {
641        let _ = write!(hash, "{byte:02x}");
642        hash
643    })
644}
645
646fn format_unread_ranges(whitelist: &FileWhitelistData) -> String {
647    whitelist
648        .get_unread_ranges()
649        .into_iter()
650        .map(|(start, end)| if start == end { start.to_string() } else { format!("{start}-{end}") })
651        .collect::<Vec<_>>()
652        .join(", ")
653}
654
655#[instrument(level = "info", skip(bash_state_arc, file_write_or_edit))]
656pub async fn handle_tool_call(
657    bash_state_arc: &Arc<Mutex<Option<BashState>>>,
658    file_write_or_edit: FileWriteOrEdit,
659) -> Result<String> {
660    let mut bash_state_guard = bash_state_arc.lock().await;
661    let bash_state = bash_state_guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
662
663    let thread_id = normalize_thread_id(&file_write_or_edit.thread_id);
664    if thread_id != bash_state.current_thread_id {
665        return Err(WinxError::ThreadIdMismatch(thread_id));
666    }
667
668    let expanded_path = expand_user(&file_write_or_edit.file_path);
669    let path = if Path::new(&expanded_path).is_absolute() {
670        PathBuf::from(&expanded_path)
671    } else {
672        bash_state.cwd.join(&expanded_path)
673    };
674
675    let path = validate_path_in_workspace(&path, &bash_state.workspace_root)
676        .map_err(|e| WinxError::PathSecurityError { path: path.clone(), message: e.to_string() })?;
677
678    let file_path_str = path.to_string_lossy().to_string();
679
680    let uses_search_replace = uses_search_replace(&file_write_or_edit);
681    let operation_allowed = if uses_search_replace {
682        bash_state.is_file_edit_allowed(&file_path_str)
683    } else {
684        bash_state.is_file_write_allowed(&file_path_str)
685    };
686
687    if !operation_allowed {
688        return Err(WinxError::FileAccessError {
689            path: path.clone(),
690            message: "File operation not allowed in current mode.".to_string(),
691        });
692    }
693
694    if path.exists() {
695        let whitelist =
696            bash_state.whitelist_for_overwrite.get(&file_path_str).ok_or_else(|| {
697                WinxError::FileAccessError {
698                    path: path.clone(),
699                    message: "Read file first before editing.".to_string(),
700                }
701            })?;
702        let original_content = fs::read_to_string(&path)?;
703        let current_hash = hash_content(&original_content);
704        if whitelist.file_hash != current_hash {
705            return Err(WinxError::FileAccessError {
706                path: path.clone(),
707                message: "File changed since last read. Re-read before editing.".to_string(),
708            });
709        }
710        if !uses_search_replace && !whitelist.is_read_enough() {
711            return Err(WinxError::FileAccessError {
712                path: path.clone(),
713                message: format!(
714                    "Read more of the file before overwriting. Unread line ranges: {}",
715                    format_unread_ranges(whitelist)
716                ),
717            });
718        }
719    }
720
721    let result = if uses_search_replace {
722        let original_content = fs::read_to_string(&path)?;
723        let blocks = parse_blocks(&file_write_or_edit.text_or_search_replace_blocks)?;
724        let new_content = apply_blocks(&original_content, &blocks)?;
725
726        fs::write(&path, &new_content)?;
727        operation_result("edited", &file_path_str, &path, &new_content)
728    } else {
729        fs::write(&path, &file_write_or_edit.text_or_search_replace_blocks)?;
730        operation_result(
731            "wrote",
732            &file_path_str,
733            &path,
734            &file_write_or_edit.text_or_search_replace_blocks,
735        )
736    };
737
738    // Update whitelist
739    let final_content = fs::read_to_string(&path)?;
740    let hash = hash_content(&final_content);
741    let total_lines = final_content.lines().count();
742
743    bash_state
744        .whitelist_for_overwrite
745        .insert(file_path_str, FileWhitelistData::new(hash, vec![(1, total_lines)], total_lines));
746    if uses_search_replace {
747        let _ = crate::utils::workspace_stats::record_edit(&bash_state.workspace_root, &path);
748    } else {
749        let _ = crate::utils::workspace_stats::record_write(&bash_state.workspace_root, &path);
750    }
751
752    Ok(result)
753}
754
755fn operation_result(action: &str, file_path: &str, path: &Path, content: &str) -> String {
756    let mut result = format!("Successfully {action} {file_path}");
757    if let Some(warning) = crate::utils::syntax::syntax_warning(path, content) {
758        let _ = write!(result, "\n\n{warning}");
759    }
760    result
761}