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*@(\d+)(?:-(\d+))?)?\s*$")
36}
37
38fn divider_marker() -> Result<&'static Regex> {
39 regex_marker(&DIVIDER_MARKER, r"(?m)^======*\s*$")
40}
41
42fn replace_marker() -> Result<&'static Regex> {
43 regex_marker(&REPLACE_MARKER, r"(?m)^>>>>>>+\s*REPLACE\s*$")
44}
45
46const MAX_FILE_SIZE: u64 = 50_000_000;
47
48#[derive(Debug, Clone, PartialEq, Eq, Default)]
49struct SearchReplaceBlock {
50 search: Vec<String>,
51 replace: Vec<String>,
52 anchor_start: Option<usize>,
58 anchor_end: Option<usize>,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62enum ToleranceKind {
63 TrimEnd,
64 IgnoreIndentation,
65 RemoveLineNumbers,
66 NormalizeCommonMistakes,
67 IgnoreWhitespace,
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71enum LineMatch {
72 Exact,
73 Tolerated(ToleranceKind),
74}
75
76impl ToleranceKind {
77 fn score(self) -> usize {
78 match self {
79 ToleranceKind::TrimEnd => 1,
80 ToleranceKind::RemoveLineNumbers | ToleranceKind::NormalizeCommonMistakes => 5,
81 ToleranceKind::IgnoreIndentation => 10,
82 ToleranceKind::IgnoreWhitespace => 50,
83 }
84 }
85
86 fn display_name(self) -> &'static str {
89 match self {
90 ToleranceKind::TrimEnd => "trailing whitespace",
91 ToleranceKind::RemoveLineNumbers => "line-number prefixes",
92 ToleranceKind::NormalizeCommonMistakes => "smart-quote/dash normalization",
93 ToleranceKind::IgnoreIndentation => "indentation",
94 ToleranceKind::IgnoreWhitespace => "all whitespace",
95 }
96 }
97}
98
99fn collect_tolerances(replacements: &[Replacement]) -> Vec<ToleranceKind> {
102 let mut out: Vec<ToleranceKind> = Vec::new();
103 for replacement in replacements {
104 for &tolerance in &replacement.tolerances {
105 if !out.contains(&tolerance) {
106 out.push(tolerance);
107 }
108 }
109 }
110 out
111}
112
113#[derive(Debug, Clone, PartialEq, Eq)]
114struct MatchCandidate {
115 start: usize,
116 end: usize,
117 score: usize,
118 tolerances: Vec<ToleranceKind>,
119 replace: Vec<String>,
120}
121
122#[derive(Debug, Clone, PartialEq, Eq)]
123struct Replacement {
124 start: usize,
125 end: usize,
126 replace: Vec<String>,
127 tolerances: Vec<ToleranceKind>,
130}
131
132fn parse_blocks(content: &str) -> Result<Vec<SearchReplaceBlock>> {
133 let mut blocks = Vec::new();
134 let lines: Vec<&str> = content.lines().collect();
135 let mut i = 0;
136
137 while i < lines.len() {
138 let anchors = search_marker()?.captures(lines[i]).map(|caps| {
139 (
140 caps.get(1).and_then(|m| m.as_str().parse::<usize>().ok()),
141 caps.get(2).and_then(|m| m.as_str().parse::<usize>().ok()),
142 )
143 });
144 if let Some((anchor_start, anchor_end)) = anchors {
145 let line_num = i + 1;
146 i += 1;
147 let mut search_lines = Vec::new();
148 while i < lines.len() && !divider_marker()?.is_match(lines[i]) {
149 if search_marker()?.is_match(lines[i]) || replace_marker()?.is_match(lines[i]) {
150 return Err(WinxError::SearchReplaceSyntaxError(format!(
151 "Line {}: stray marker in SEARCH block",
152 i + 1
153 )));
154 }
155 search_lines.push(lines[i]);
156 i += 1;
157 }
158
159 if i >= lines.len() {
160 return Err(WinxError::SearchReplaceSyntaxError(format!(
161 "Line {line_num}: unclosed SEARCH block - missing ======= marker"
162 )));
163 }
164
165 if search_lines.is_empty() {
166 return Err(WinxError::SearchReplaceSyntaxError(format!(
167 "Line {line_num}: SEARCH block cannot be empty"
168 )));
169 }
170
171 i += 1;
172 let mut replace_lines = Vec::new();
173 while i < lines.len() && !replace_marker()?.is_match(lines[i]) {
174 if search_marker()?.is_match(lines[i]) || divider_marker()?.is_match(lines[i]) {
175 return Err(WinxError::SearchReplaceSyntaxError(format!(
176 "Line {}: stray marker in REPLACE block",
177 i + 1
178 )));
179 }
180 replace_lines.push(lines[i]);
181 i += 1;
182 }
183
184 if i >= lines.len() {
185 return Err(WinxError::SearchReplaceSyntaxError(format!(
186 "Line {line_num}: unclosed block - missing REPLACE marker"
187 )));
188 }
189
190 blocks.push(SearchReplaceBlock {
191 search: search_lines.into_iter().map(str::to_string).collect(),
192 replace: replace_lines.into_iter().map(str::to_string).collect(),
193 anchor_start,
194 anchor_end,
195 });
196 } else if divider_marker()?.is_match(lines[i]) || replace_marker()?.is_match(lines[i]) {
197 return Err(WinxError::SearchReplaceSyntaxError(format!(
198 "Line {}: stray marker outside block",
199 i + 1
200 )));
201 }
202 i += 1;
203 }
204
205 if blocks.is_empty() {
206 return Err(WinxError::SearchReplaceSyntaxError("No valid blocks found".to_string()));
207 }
208
209 Ok(blocks)
210}
211
212fn apply_blocks_with_unescape_retry(
216 original: &str,
217 raw: &str,
218) -> Result<(String, Vec<ToleranceKind>)> {
219 let blocks = parse_blocks(raw)?;
220 match apply_blocks(original, &blocks) {
221 Ok(result) => Ok(result),
222 Err(first_err) => {
223 let unescaped = raw.replace("\\\"", "\"");
224 if unescaped == raw {
225 return Err(first_err);
226 }
227 let retry_blocks = parse_blocks(&unescaped).map_err(|_| first_err)?;
228 apply_blocks(original, &retry_blocks)
229 }
230 }
231}
232
233fn apply_blocks(
234 content: &str,
235 blocks: &[SearchReplaceBlock],
236) -> Result<(String, Vec<ToleranceKind>)> {
237 let uses_crlf = content.contains("\r\n");
243 let normalized;
244 let content_lf: &str = if uses_crlf {
245 normalized = content.replace("\r\n", "\n");
246 &normalized
247 } else {
248 content
249 };
250
251 let original_lines = split_lines(content_lf);
252 let (edited, tolerances) =
253 apply_blocks_ordered(&original_lines, blocks).or_else(|ordered_error| {
254 if blocks.len() == 1 {
255 Err(ordered_error)
256 } else {
257 apply_blocks_individually(&original_lines, blocks)
258 }
259 })?;
260
261 let joined = edited.join("\n");
262 let content_out = if uses_crlf { joined.replace('\n', "\r\n") } else { joined };
263 Ok((content_out, tolerances))
264}
265
266fn split_lines(content: &str) -> Vec<String> {
267 content.split('\n').map(str::to_string).collect()
268}
269
270fn write_no_follow(path: &Path, content: &[u8]) -> std::io::Result<()> {
289 use std::io::Error;
290
291 let parent = path.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new("."));
292 let mut tmp =
296 tempfile::Builder::new().prefix(".winx-tmp-").tempfile_in(parent).map_err(|e| {
297 Error::new(e.kind(), format!("create temp file in {}: {e}", parent.display()))
298 })?;
299
300 if let Ok(meta) = fs::symlink_metadata(path) {
303 if meta.file_type().is_file() {
304 let _ = tmp.as_file().set_permissions(meta.permissions());
305 }
306 }
307
308 tmp.write_all(content)?;
309 tmp.as_file().sync_all()?;
310 tmp.persist(path).map_err(|e| e.error)?;
313 Ok(())
314}
315
316const MAX_CANDIDATES_PER_BLOCK: usize = 64;
319const MAX_SEARCH_NODES: u32 = 50_000;
322const MAX_TOTAL_TOLERANCE_SCORE: usize = 1000;
326
327fn apply_blocks_ordered(
328 lines: &[String],
329 blocks: &[SearchReplaceBlock],
330) -> Result<(Vec<String>, Vec<ToleranceKind>)> {
331 let mut budget = MAX_SEARCH_NODES;
332 let (score, replacements) = best_ordered_replacements(lines, blocks, 0, 0, &mut budget)?;
333 if score > MAX_TOTAL_TOLERANCE_SCORE {
334 return Err(WinxError::SearchBlockNotFound(format!(
335 "SEARCH blocks only matched very loosely (tolerance score {score} over limit \
336 {MAX_TOTAL_TOLERANCE_SCORE}). The file likely changed since you read it — re-read it \
337 and make the SEARCH text match the current content exactly."
338 )));
339 }
340 let tolerances = collect_tolerances(&replacements);
341 Ok((apply_replacements(lines, &replacements), tolerances))
342}
343
344fn best_ordered_replacements(
345 lines: &[String],
346 blocks: &[SearchReplaceBlock],
347 block_index: usize,
348 offset: usize,
349 budget: &mut u32,
350) -> Result<(usize, Vec<Replacement>)> {
351 if block_index >= blocks.len() {
352 return Ok((0, Vec::new()));
353 }
354 if *budget == 0 {
355 return Err(WinxError::SearchBlockNotFound(
356 "Search/replace is too ambiguous (too many candidate combinations). Add more \
357 surrounding context so each SEARCH block matches a unique location."
358 .to_string(),
359 ));
360 }
361 *budget -= 1;
362
363 let block = &blocks[block_index];
364 let candidates = find_candidates(lines, block, offset);
365 if candidates.is_empty() {
366 return Err(not_found_error(block, lines, offset));
367 }
368 if candidates.len() > MAX_CANDIDATES_PER_BLOCK {
369 return Err(WinxError::SearchBlockNotFound(format!(
370 "A SEARCH block matches {} locations (limit {MAX_CANDIDATES_PER_BLOCK}); add more \
371 surrounding context to make it unique:\n{}",
372 candidates.len(),
373 block.search.join("\n")
374 )));
375 }
376
377 let mut valid_paths = Vec::new();
378 for candidate in candidates {
379 if let Ok((tail_score, mut tail)) =
380 best_ordered_replacements(lines, blocks, block_index + 1, candidate.end, budget)
381 {
382 let mut path = vec![Replacement {
383 start: candidate.start,
384 end: candidate.end,
385 replace: candidate.replace,
386 tolerances: candidate.tolerances,
387 }];
388 path.append(&mut tail);
389 valid_paths.push((candidate.score + tail_score, path));
390 }
391 }
392
393 select_unique_best_path(block, valid_paths)
394}
395
396fn select_unique_best_path(
397 block: &SearchReplaceBlock,
398 paths: Vec<(usize, Vec<Replacement>)>,
399) -> Result<(usize, Vec<Replacement>)> {
400 let Some(best_score) = paths.iter().map(|(score, _)| *score).min() else {
401 return Err(WinxError::SearchBlockNotFound(format!(
402 "Block not found: {}",
403 block.search.join("\n")
404 )));
405 };
406
407 let best_paths: Vec<(usize, Vec<Replacement>)> =
408 paths.into_iter().filter(|(score, _)| *score == best_score).collect();
409
410 if best_paths.len() == 1 {
411 return best_paths.into_iter().next().ok_or_else(|| {
412 WinxError::SearchBlockNotFound(format!("Block not found: {}", block.search.join("\n")))
413 });
414 }
415
416 let ranges = best_paths
417 .iter()
418 .filter_map(|(_, reps)| reps.first().map(|r| format!("{}-{}", r.start + 1, r.end)))
419 .collect::<Vec<_>>()
420 .join(", ");
421 Err(WinxError::SearchBlockAmbiguous {
422 block_content: block.search.join("\n"),
423 match_count: best_paths.len(),
424 suggestions: vec![
425 format!("Equally-good matches at lines: {ranges}"),
426 "Add more context before or after this block to make it unique.".to_string(),
427 ],
428 })
429}
430
431fn apply_blocks_individually(
432 lines: &[String],
433 blocks: &[SearchReplaceBlock],
434) -> Result<(Vec<String>, Vec<ToleranceKind>)> {
435 let mut running_lines = lines.to_vec();
436 let mut total_score = 0usize;
437 let mut tolerances: Vec<ToleranceKind> = Vec::new();
438 for block in blocks {
439 let candidate = select_unique_candidate(block, find_candidates(&running_lines, block, 0))?;
440 total_score = total_score.saturating_add(candidate.score);
444 if total_score > MAX_TOTAL_TOLERANCE_SCORE {
445 return Err(WinxError::SearchBlockNotFound(format!(
446 "SEARCH blocks only matched very loosely (tolerance score {total_score} over \
447 limit {MAX_TOTAL_TOLERANCE_SCORE}). The file likely changed since you read it — \
448 re-read it and make the SEARCH text match the current content exactly."
449 )));
450 }
451 for &tolerance in &candidate.tolerances {
452 if !tolerances.contains(&tolerance) {
453 tolerances.push(tolerance);
454 }
455 }
456 running_lines = apply_replacements(
457 &running_lines,
458 &[Replacement {
459 start: candidate.start,
460 end: candidate.end,
461 replace: candidate.replace,
462 tolerances: Vec::new(),
463 }],
464 );
465 }
466 Ok((running_lines, tolerances))
467}
468
469fn select_unique_candidate(
470 block: &SearchReplaceBlock,
471 candidates: Vec<MatchCandidate>,
472) -> Result<MatchCandidate> {
473 if candidates.is_empty() {
474 return Err(WinxError::SearchBlockNotFound(format!(
475 "Block not found: {}",
476 block.search.join("\n")
477 )));
478 }
479
480 let best_score = candidates.iter().map(|candidate| candidate.score).min().unwrap_or(0);
481 let best: Vec<MatchCandidate> =
482 candidates.into_iter().filter(|candidate| candidate.score == best_score).collect();
483
484 if best.len() == 1 {
485 return best.into_iter().next().ok_or_else(|| {
486 WinxError::SearchBlockNotFound(format!("Block not found: {}", block.search.join("\n")))
487 });
488 }
489
490 let ranges = best
491 .iter()
492 .map(|candidate| format!("{}-{}", candidate.start + 1, candidate.end))
493 .collect::<Vec<_>>()
494 .join(", ");
495 Err(WinxError::SearchBlockAmbiguous {
496 block_content: block.search.join("\n"),
497 match_count: best.len(),
498 suggestions: vec![
499 format!("Equally-good matches at lines: {ranges}"),
500 "Add more context to make the search block unique.".to_string(),
501 ],
502 })
503}
504
505fn apply_replacements(lines: &[String], replacements: &[Replacement]) -> Vec<String> {
506 let mut edited = Vec::new();
507 let mut cursor = 0;
508
509 for replacement in replacements {
510 edited.extend_from_slice(&lines[cursor..replacement.start]);
511 edited.extend(replacement.replace.clone());
512 cursor = replacement.end;
513 }
514
515 edited.extend_from_slice(&lines[cursor..]);
516 edited
517}
518
519fn find_candidates(
520 lines: &[String],
521 block: &SearchReplaceBlock,
522 offset: usize,
523) -> Vec<MatchCandidate> {
524 let mut candidates = find_contiguous_candidates(lines, block, offset, false);
525 if candidates.is_empty() {
526 candidates = find_single_line_substring_candidates(lines, block, offset);
527 }
528 if candidates.is_empty() {
529 candidates = find_contiguous_candidates(lines, block, offset, true);
530 }
531 narrow_to_anchor(candidates, block)
532}
533
534fn narrow_to_anchor(
539 candidates: Vec<MatchCandidate>,
540 block: &SearchReplaceBlock,
541) -> Vec<MatchCandidate> {
542 let Some(start) = block.anchor_start else {
543 return candidates;
544 };
545 let lo = start.saturating_sub(1); let hi = block.anchor_end.unwrap_or(start).saturating_sub(1);
547 let anchored: Vec<MatchCandidate> =
548 candidates.iter().filter(|c| c.start >= lo && c.start <= hi).cloned().collect();
549 if anchored.is_empty() {
550 candidates
551 } else {
552 anchored
553 }
554}
555
556fn find_single_line_substring_candidates(
557 lines: &[String],
558 block: &SearchReplaceBlock,
559 offset: usize,
560) -> Vec<MatchCandidate> {
561 if block.search.len() != 1 {
562 return Vec::new();
563 }
564
565 let search = &block.search[0];
566 if search.is_empty() {
567 return Vec::new();
568 }
569
570 let replace = block.replace.join("\n");
571 lines
572 .iter()
573 .enumerate()
574 .skip(offset)
575 .flat_map(|(index, line)| {
576 let replace = replace.clone();
577 line.match_indices(search).map(move |(byte_index, _)| {
578 let mut replaced_line = line.clone();
579 replaced_line.replace_range(byte_index..byte_index + search.len(), &replace);
580 MatchCandidate {
581 start: index,
582 end: index + 1,
583 score: 0,
584 tolerances: Vec::new(),
585 replace: split_lines(&replaced_line),
586 }
587 })
588 })
589 .collect()
590}
591
592fn find_contiguous_candidates(
593 lines: &[String],
594 block: &SearchReplaceBlock,
595 offset: usize,
596 ignore_empty_lines: bool,
597) -> Vec<MatchCandidate> {
598 let search_lines = if ignore_empty_lines {
599 block.search.iter().filter(|line| !line.trim().is_empty()).cloned().collect()
600 } else {
601 block.search.clone()
602 };
603
604 if search_lines.is_empty() || lines.len().saturating_sub(offset) < search_lines.len() {
605 return Vec::new();
606 }
607
608 if ignore_empty_lines {
609 return find_empty_line_tolerant_candidates(lines, block, offset, &search_lines);
610 }
611
612 let max_start = lines.len() - search_lines.len();
613 (offset..=max_start)
614 .filter_map(|start| {
615 let end = start + search_lines.len();
616 let actual_lines: Vec<&String> = lines[start..end].iter().collect();
617 match_candidate(lines, &actual_lines, &search_lines, block, start, end, false)
618 })
619 .collect()
620}
621
622fn find_empty_line_tolerant_candidates(
623 lines: &[String],
624 block: &SearchReplaceBlock,
625 offset: usize,
626 search_lines: &[String],
627) -> Vec<MatchCandidate> {
628 let compact_lines: Vec<(usize, &String)> =
629 lines.iter().enumerate().skip(offset).filter(|(_, line)| !line.trim().is_empty()).collect();
630
631 if compact_lines.len() < search_lines.len() {
632 return Vec::new();
633 }
634
635 let max_start = compact_lines.len() - search_lines.len();
636 (0..=max_start)
637 .filter_map(|compact_start| {
638 let compact_end = compact_start + search_lines.len();
639 let start = compact_lines[compact_start].0;
640 let end = compact_lines[compact_end - 1].0 + 1;
641 let actual_lines: Vec<&String> =
642 compact_lines[compact_start..compact_end].iter().map(|(_, line)| *line).collect();
643 match_candidate(lines, &actual_lines, search_lines, block, start, end, true)
644 })
645 .collect()
646}
647
648fn match_candidate(
649 lines: &[String],
650 actual_lines: &[&String],
651 search_lines: &[String],
652 block: &SearchReplaceBlock,
653 start: usize,
654 end: usize,
655 ignore_empty_lines: bool,
656) -> Option<MatchCandidate> {
657 let mut tolerances = Vec::new();
658 let mut score = 0;
659
660 for (actual, expected) in actual_lines.iter().zip(search_lines) {
661 let line_match = matching_tolerance(actual, expected)?;
662 if let LineMatch::Tolerated(tolerance) = line_match {
663 score += tolerance.score();
664 if !tolerances.contains(&tolerance) {
665 tolerances.push(tolerance);
666 }
667 }
668 }
669
670 let mut replace = if ignore_empty_lines {
671 trim_empty_edge_lines(&block.replace)
672 } else {
673 block.replace.clone()
674 };
675 if tolerances.contains(&ToleranceKind::RemoveLineNumbers) {
676 replace = replace.into_iter().map(|line| remove_leading_line_number(&line)).collect();
677 }
678 if tolerances.contains(&ToleranceKind::IgnoreIndentation) {
679 let matched = &lines[start..end];
680 replace = fix_indentation(matched, search_lines, &replace);
681 }
682
683 Some(MatchCandidate { start, end, score, tolerances, replace })
684}
685
686fn matching_tolerance(actual: &str, expected: &str) -> Option<LineMatch> {
687 if actual == expected {
688 return Some(LineMatch::Exact);
689 }
690 if actual.trim_end() == expected.trim_end() {
691 return Some(LineMatch::Tolerated(ToleranceKind::TrimEnd));
692 }
693 if actual.trim_start() == expected.trim_start() {
694 return Some(LineMatch::Tolerated(ToleranceKind::IgnoreIndentation));
695 }
696 if remove_leading_line_number(actual) == remove_leading_line_number(expected) {
697 return Some(LineMatch::Tolerated(ToleranceKind::RemoveLineNumbers));
698 }
699 if normalize_common_mistakes(actual) == normalize_common_mistakes(expected) {
700 return Some(LineMatch::Tolerated(ToleranceKind::NormalizeCommonMistakes));
701 }
702 if remove_ascii_whitespace(actual) == remove_ascii_whitespace(expected) {
703 return Some(LineMatch::Tolerated(ToleranceKind::IgnoreWhitespace));
704 }
705 None
706}
707
708fn remove_ascii_whitespace(value: &str) -> String {
709 value.chars().filter(|c| !c.is_whitespace()).collect()
710}
711
712fn remove_leading_line_number(value: &str) -> String {
713 value
714 .split_once(' ')
715 .filter(|(prefix, _)| !prefix.is_empty() && prefix.chars().all(|c| c.is_ascii_digit()))
716 .map_or_else(|| value.trim_end().to_string(), |(_, rest)| rest.trim_end().to_string())
717}
718
719fn normalize_common_mistakes(value: &str) -> String {
720 let mut normalized = String::with_capacity(value.len());
721 for character in value.chars() {
722 match character {
723 '\u{2018}' | '\u{2019}' | '\u{201b}' | '\u{2032}' => normalized.push('\''),
724 '\u{201a}' => normalized.push(','),
725 '\u{201c}' | '\u{201d}' | '\u{201f}' | '\u{2033}' => normalized.push('"'),
726 '\u{2039}' => normalized.push('<'),
727 '\u{203a}' => normalized.push('>'),
728 '\u{2010}' | '\u{2011}' | '\u{2012}' | '\u{2013}' | '\u{2014}' | '\u{2015}'
729 | '\u{2212}' => normalized.push('-'),
730 '\u{2026}' => normalized.push_str("..."),
731 other => normalized.push(other),
732 }
733 }
734 normalized.trim_end().to_string()
735}
736
737fn fix_indentation(
738 matched_lines: &[String],
739 searched_lines: &[String],
740 replaced_lines: &[String],
741) -> Vec<String> {
742 if matched_lines.is_empty() || searched_lines.is_empty() || replaced_lines.is_empty() {
743 return replaced_lines.to_vec();
744 }
745
746 let matched_indents = non_empty_indents(matched_lines);
747 let searched_indents = non_empty_indents(searched_lines);
748 if matched_indents.len() != searched_indents.len() || matched_indents.is_empty() {
749 return replaced_lines.to_vec();
750 }
751
752 let diffs: Vec<isize> = matched_indents
755 .iter()
756 .zip(&searched_indents)
757 .map(|(matched, searched)| {
758 searched.chars().count() as isize - matched.chars().count() as isize
759 })
760 .collect();
761 let Some(&first_diff) = diffs.first() else {
762 return replaced_lines.to_vec();
763 };
764 if first_diff == 0 || !diffs.iter().all(|diff| *diff == first_diff) {
765 return replaced_lines.to_vec();
766 }
767
768 adjust_replacement_indentation(replaced_lines, &matched_indents[0], first_diff)
769}
770
771fn non_empty_indents(lines: &[String]) -> Vec<String> {
772 lines
773 .iter()
774 .filter(|line| !line.trim().is_empty())
775 .map(|line| line.chars().take_while(|c| c.is_whitespace()).collect())
776 .collect()
777}
778
779fn adjust_replacement_indentation(
780 replaced_lines: &[String],
781 matched_indent: &str,
782 diff: isize,
783) -> Vec<String> {
784 if diff < 0 {
788 let prefix_len = usize::try_from(-diff).unwrap_or(0);
789 let prefix: String = matched_indent.chars().take(prefix_len).collect();
790 return replaced_lines.iter().map(|line| format!("{prefix}{line}")).collect();
791 }
792
793 let remove_len = usize::try_from(diff).unwrap_or(0);
794 if !replaced_lines.iter().all(|line| removable_indent(line, remove_len)) {
795 return replaced_lines.to_vec();
796 }
797 replaced_lines.iter().map(|line| line.chars().skip(remove_len).collect()).collect()
798}
799
800fn removable_indent(line: &str, remove_len: usize) -> bool {
801 line.chars().take(remove_len).filter(|c| c.is_whitespace()).count() == remove_len
802}
803
804fn trim_empty_edge_lines(lines: &[String]) -> Vec<String> {
805 let Some(first) = lines.iter().position(|line| !line.trim().is_empty()) else {
806 return Vec::new();
807 };
808 let last = lines.iter().rposition(|line| !line.trim().is_empty()).unwrap_or(first);
809 lines[first..=last].to_vec()
810}
811
812const SNIPPET_CONTEXT_LINES: usize = 10;
815
816fn not_found_error(block: &SearchReplaceBlock, lines: &[String], offset: usize) -> WinxError {
817 let (snippet, similarity) = closest_snippet(lines, offset, &block.search);
818 WinxError::SearchBlockNotFound(format!(
819 "Block not found in file. The SEARCH block below didn't match anywhere:\n{}\n\n\
820 Closest matching context in the file ({:.0}% similar; lines marked ~ are the ones that \
821 diverged from your SEARCH — re-read the file and copy the text exactly):\n{}",
822 block.search.join("\n"),
823 similarity * 100.0,
824 snippet
825 ))
826}
827
828fn closest_snippet(lines: &[String], offset: usize, search: &[String]) -> (String, f64) {
832 let window = search.len().max(1);
833 if lines.is_empty() || offset >= lines.len() {
834 return (String::new(), 0.0);
835 }
836
837 let max_start = lines.len().saturating_sub(window);
838 let mut best_start = offset;
839 let mut best_score = f64::MIN;
840 for start in offset..=max_start {
841 let score = snippet_similarity(&lines[start..(start + window)], search);
842 if score > best_score {
843 best_score = score;
844 best_start = start;
845 }
846 }
847
848 let context_start = best_start.saturating_sub(SNIPPET_CONTEXT_LINES);
851 let context_end = (best_start + window + SNIPPET_CONTEXT_LINES).min(lines.len());
852 let snippet = lines[context_start..context_end]
853 .iter()
854 .enumerate()
855 .map(|(index, line)| {
856 let abs = context_start + index;
857 let marker = if abs >= best_start && abs < best_start + window {
861 let search_line = &search[abs - best_start];
862 if strsim::normalized_levenshtein(line.trim(), search_line.trim()) < 0.6 {
863 '~'
864 } else {
865 ' '
866 }
867 } else {
868 ' '
869 };
870 format!("{:>6} {marker} {line}", abs + 1)
871 })
872 .collect::<Vec<_>>()
873 .join("\n");
874 let similarity = (best_score / window as f64).clamp(0.0, 1.0);
877 (snippet, similarity)
878}
879
880fn snippet_similarity(candidate: &[String], search: &[String]) -> f64 {
881 candidate
882 .iter()
883 .zip(search)
884 .map(|(candidate_line, search_line)| {
885 strsim::normalized_levenshtein(candidate_line.trim(), search_line.trim())
886 })
887 .sum::<f64>()
888 - candidate.len().abs_diff(search.len()) as f64
889}
890
891fn uses_search_replace(file_write_or_edit: &FileWriteOrEdit) -> bool {
892 if file_write_or_edit.percentage_to_change <= 50 {
893 return true;
894 }
895
896 let first_content_line =
897 file_write_or_edit.text_or_search_replace_blocks.trim_start().lines().next();
898 first_content_line.is_some_and(|line| search_marker().is_ok_and(|marker| marker.is_match(line)))
899}
900
901fn hash_content(content: &str) -> String {
902 let digest = Sha256::digest(content.as_bytes());
903 digest.iter().fold(String::with_capacity(digest.len() * 2), |mut hash, byte| {
904 let _ = write!(hash, "{byte:02x}");
905 hash
906 })
907}
908
909fn format_unread_ranges(whitelist: &FileWhitelistData) -> String {
910 whitelist
911 .get_unread_ranges()
912 .into_iter()
913 .map(|(start, end)| if start == end { start.to_string() } else { format!("{start}-{end}") })
914 .collect::<Vec<_>>()
915 .join(", ")
916}
917
918#[instrument(level = "info", skip(bash_state_arc, file_write_or_edit))]
919pub async fn handle_tool_call(
920 bash_state_arc: &Arc<Mutex<Option<BashState>>>,
921 file_write_or_edit: FileWriteOrEdit,
922) -> Result<String> {
923 let mut bash_state_guard = bash_state_arc.lock().await;
924 let bash_state = bash_state_guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
925
926 let thread_id = normalize_thread_id(&file_write_or_edit.thread_id);
927 if thread_id != bash_state.current_thread_id {
928 return Err(WinxError::ThreadIdMismatch(thread_id));
929 }
930
931 let expanded_path = expand_user(&file_write_or_edit.file_path);
932 let path = if Path::new(&expanded_path).is_absolute() {
933 PathBuf::from(&expanded_path)
934 } else {
935 bash_state.cwd.join(&expanded_path)
936 };
937
938 let path = validate_path_in_workspace(&path, &bash_state.workspace_root)
939 .map_err(|e| WinxError::PathSecurityError { path: path.clone(), message: e.to_string() })?;
940
941 let file_path_str = path.to_string_lossy().to_string();
942
943 let uses_search_replace = uses_search_replace(&file_write_or_edit);
944 let operation_allowed = if uses_search_replace {
945 bash_state.is_file_edit_allowed(&file_path_str)
946 } else {
947 bash_state.is_file_write_allowed(&file_path_str)
948 };
949
950 if !operation_allowed {
951 return Err(WinxError::FileAccessError {
952 path: path.clone(),
953 message: "File operation not allowed in current mode.".to_string(),
954 });
955 }
956
957 if path.exists() {
958 let whitelist =
959 bash_state.whitelist_for_overwrite.get(&file_path_str).ok_or_else(|| {
960 WinxError::FileAccessError {
961 path: path.clone(),
962 message: format!(
963 "This file exists but hasn't been read in this session. \
964 Call ReadFiles on {file_path_str} first, then retry the edit \
965 (winx requires a fresh read so edits are never applied blind)."
966 ),
967 }
968 })?;
969 let original_content = fs::read_to_string(&path)?;
970 let current_hash = hash_content(&original_content);
971 if whitelist.file_hash != current_hash {
972 return Err(WinxError::FileAccessError {
973 path: path.clone(),
974 message: format!(
975 "{file_path_str} changed on disk since you last read it. \
976 Call ReadFiles again to get the current content, then retry the edit."
977 ),
978 });
979 }
980 if !uses_search_replace && !whitelist.is_read_enough() {
981 return Err(WinxError::FileAccessError {
982 path: path.clone(),
983 message: format!(
984 "Read more of the file before overwriting. Unread line ranges: {}",
985 format_unread_ranges(whitelist)
986 ),
987 });
988 }
989 }
990
991 let result = if uses_search_replace {
992 let original_content = fs::read_to_string(&path)?;
993 let (new_content, tolerances) = apply_blocks_with_unescape_retry(
994 &original_content,
995 &file_write_or_edit.text_or_search_replace_blocks,
996 )?;
997
998 write_no_follow(&path, new_content.as_bytes())?;
999 operation_result("edited", &file_path_str, &path, &new_content, &tolerances)
1000 } else {
1001 write_no_follow(&path, file_write_or_edit.text_or_search_replace_blocks.as_bytes())?;
1002 operation_result(
1003 "wrote",
1004 &file_path_str,
1005 &path,
1006 &file_write_or_edit.text_or_search_replace_blocks,
1007 &[],
1008 )
1009 };
1010
1011 let final_content = fs::read_to_string(&path)?;
1013 let hash = hash_content(&final_content);
1014 let total_lines = final_content.lines().count();
1015
1016 bash_state
1017 .whitelist_for_overwrite
1018 .insert(file_path_str, FileWhitelistData::new(hash, vec![(1, total_lines)], total_lines));
1019 if uses_search_replace {
1020 if let Err(e) =
1021 crate::utils::workspace_stats::record_edit(&bash_state.workspace_root, &path)
1022 {
1023 debug!("failed to record edit stats: {e}");
1024 }
1025 } else if let Err(e) =
1026 crate::utils::workspace_stats::record_write(&bash_state.workspace_root, &path)
1027 {
1028 debug!("failed to record write stats: {e}");
1029 }
1030
1031 Ok(result)
1032}
1033
1034fn operation_result(
1035 action: &str,
1036 file_path: &str,
1037 path: &Path,
1038 content: &str,
1039 tolerances: &[ToleranceKind],
1040) -> String {
1041 let mut result = format!("Successfully {action} {file_path}");
1042 if !tolerances.is_empty() {
1043 let names = tolerances.iter().map(|t| t.display_name()).collect::<Vec<_>>().join(", ");
1046 let _ = write!(
1047 result,
1048 "\n\nNote: matched after tolerating {names} differences — your SEARCH text didn't \
1049 match the file exactly. Re-read the file if you expected an exact match."
1050 );
1051 }
1052 if let Some(warning) = crate::utils::syntax::syntax_warning(path, content) {
1053 let _ = write!(result, "\n\n{warning}");
1054 }
1055 result
1056}
1057
1058#[cfg(test)]
1059mod indentation_tests {
1060 use super::*;
1061
1062 #[test]
1067 fn fix_indentation_adds_multibyte_indent_without_panic() {
1068 let matched = vec!["\u{3000}\u{3000}x".to_string()];
1070 let searched = vec!["\u{3000}x".to_string()];
1071 let replaced = vec!["y".to_string()];
1072 let out = fix_indentation(&matched, &searched, &replaced);
1073 assert_eq!(out, vec!["\u{3000}y".to_string()]);
1074 }
1075
1076 #[test]
1077 fn fix_indentation_removes_multibyte_indent_without_panic() {
1078 let matched = vec!["\u{3000}x".to_string()];
1080 let searched = vec!["\u{3000}\u{3000}x".to_string()];
1081 let replaced = vec!["\u{3000}y".to_string()];
1082 let out = fix_indentation(&matched, &searched, &replaced);
1083 assert_eq!(out, vec!["y".to_string()]);
1084 }
1085
1086 #[test]
1087 fn apply_blocks_preserves_crlf_endings() -> Result<()> {
1088 let content = "line one\r\nline two\r\nline three\r\n";
1091 let block = SearchReplaceBlock {
1092 search: vec!["line two".to_string()],
1093 replace: vec!["line TWO".to_string()],
1094 ..Default::default()
1095 };
1096 let (out, _) = apply_blocks(content, &[block])?;
1097 assert_eq!(out, "line one\r\nline TWO\r\nline three\r\n");
1098 Ok(())
1099 }
1100
1101 #[test]
1102 fn apply_blocks_leaves_lf_files_as_lf() -> Result<()> {
1103 let content = "a\nb\nc\n";
1104 let block = SearchReplaceBlock {
1105 search: vec!["b".to_string()],
1106 replace: vec!["B".to_string()],
1107 ..Default::default()
1108 };
1109 let (out, _) = apply_blocks(content, &[block])?;
1110 assert_eq!(out, "a\nB\nc\n");
1111 assert!(!out.contains('\r'));
1112 Ok(())
1113 }
1114
1115 #[test]
1116 fn apply_blocks_reports_indentation_tolerance() -> Result<()> {
1117 let content = " alpha\n beta\n";
1120 let block = SearchReplaceBlock {
1121 search: vec!["alpha".to_string(), "beta".to_string()],
1122 replace: vec!["alpha".to_string(), "BETA".to_string()],
1123 ..Default::default()
1124 };
1125 let (_out, tolerances) = apply_blocks(content, &[block])?;
1126 assert!(!tolerances.is_empty(), "indentation mismatch should report a tolerance");
1127 Ok(())
1128 }
1129
1130 #[test]
1131 fn apply_blocks_exact_match_reports_no_tolerances() -> Result<()> {
1132 let content = "a\nb\nc\n";
1133 let block = SearchReplaceBlock {
1134 search: vec!["b".to_string()],
1135 replace: vec!["B".to_string()],
1136 ..Default::default()
1137 };
1138 let (_out, tolerances) = apply_blocks(content, &[block])?;
1139 assert!(tolerances.is_empty(), "exact match must not report tolerances");
1140 Ok(())
1141 }
1142
1143 #[test]
1144 fn anchor_parses_start_and_range() -> Result<()> {
1145 let ranged = parse_blocks("<<<<<<< SEARCH @5-8\nfoo\n=======\nbar\n>>>>>>> REPLACE")?;
1146 assert_eq!(ranged[0].anchor_start, Some(5));
1147 assert_eq!(ranged[0].anchor_end, Some(8));
1148 let single = parse_blocks("<<<<<<< SEARCH @3\nfoo\n=======\nbar\n>>>>>>> REPLACE")?;
1149 assert_eq!(single[0].anchor_start, Some(3));
1150 assert_eq!(single[0].anchor_end, None);
1151 let plain = parse_blocks("<<<<<<< SEARCH\nfoo\n=======\nbar\n>>>>>>> REPLACE")?;
1152 assert_eq!(plain[0].anchor_start, None);
1153 Ok(())
1154 }
1155
1156 #[test]
1157 fn anchor_disambiguates_a_repeated_block() -> Result<()> {
1158 let content = "x\nx\nx\n";
1160 let raw = "<<<<<<< SEARCH @3\nx\n=======\nY\n>>>>>>> REPLACE";
1161 let (out, _) = apply_blocks_with_unescape_retry(content, raw)?;
1162 assert_eq!(out, "x\nx\nY\n", "anchor @3 must edit only the 3rd line");
1163 Ok(())
1164 }
1165
1166 #[test]
1167 fn stale_anchor_falls_back_to_normal_search() -> Result<()> {
1168 let content = "a\nx\nb\n";
1170 let raw = "<<<<<<< SEARCH @99\nx\n=======\nY\n>>>>>>> REPLACE";
1171 let (out, _) = apply_blocks_with_unescape_retry(content, raw)?;
1172 assert_eq!(out, "a\nY\nb\n", "stale anchor should fall back, not fail");
1173 Ok(())
1174 }
1175}