1use 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_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 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
218fn 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 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 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 tmp.persist(path).map_err(|e| e.error)?;
261 Ok(())
262}
263
264const MAX_CANDIDATES_PER_BLOCK: usize = 64;
267const MAX_SEARCH_NODES: u32 = 50_000;
270const 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 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 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 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
708const 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 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 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 #[test]
915 fn fix_indentation_adds_multibyte_indent_without_panic() {
916 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 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 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}