1use regex::Regex;
7use sha2::{Digest, Sha256};
8use similar::{ChangeTag, TextDiff};
9use std::fmt::Write as FmtWrite;
10use std::fs;
11use std::io::{BufWriter, Write};
12use std::path::{Path, PathBuf};
13use std::sync::{Arc, OnceLock};
14use tokio::sync::Mutex;
15use tracing::{debug, error, info, instrument, warn};
16
17use crate::errors::{Result, WinxError};
18use crate::state::bash_state::{BashState, FileWhitelistData};
19use crate::types::{normalize_thread_id, FileWriteOrEdit};
20use crate::utils::path::{expand_user, validate_path_in_workspace};
21
22static SEARCH_MARKER: OnceLock<std::result::Result<Regex, regex::Error>> = OnceLock::new();
23static DIVIDER_MARKER: OnceLock<std::result::Result<Regex, regex::Error>> = OnceLock::new();
24static REPLACE_MARKER: OnceLock<std::result::Result<Regex, regex::Error>> = OnceLock::new();
25
26fn regex_marker(
27 marker: &'static OnceLock<std::result::Result<Regex, regex::Error>>,
28 pattern: &'static str,
29) -> Result<&'static Regex> {
30 marker.get_or_init(|| Regex::new(pattern)).as_ref().map_err(|error| {
31 WinxError::ArgumentParseError(format!("Invalid edit marker regex: {error}"))
32 })
33}
34
35fn search_marker() -> Result<&'static Regex> {
36 regex_marker(&SEARCH_MARKER, r"(?m)^<<<<<<+\s*SEARCH>?(?:\s*@(\d+)(?:-(\d+))?)?\s*$")
37}
38
39fn divider_marker() -> Result<&'static Regex> {
40 regex_marker(&DIVIDER_MARKER, r"(?m)^======*\s*$")
41}
42
43fn replace_marker() -> Result<&'static Regex> {
44 regex_marker(&REPLACE_MARKER, r"(?m)^>>>>>>+\s*REPLACE\s*$")
45}
46
47const MAX_FILE_SIZE: u64 = 50_000_000;
48
49#[derive(Debug, Clone, PartialEq, Eq, Default)]
50struct SearchReplaceBlock {
51 search: Vec<String>,
52 replace: Vec<String>,
53 anchor_start: Option<usize>,
59 anchor_end: Option<usize>,
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63enum ToleranceKind {
64 TrimEnd,
65 IgnoreIndentation,
66 RemoveLineNumbers,
67 NormalizeCommonMistakes,
68 IgnoreWhitespace,
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72enum LineMatch {
73 Exact,
74 Tolerated(ToleranceKind),
75}
76
77impl ToleranceKind {
78 fn score(self) -> usize {
79 match self {
80 ToleranceKind::TrimEnd => 1,
81 ToleranceKind::RemoveLineNumbers | ToleranceKind::NormalizeCommonMistakes => 5,
82 ToleranceKind::IgnoreIndentation => 10,
83 ToleranceKind::IgnoreWhitespace => 50,
84 }
85 }
86
87 fn display_name(self) -> &'static str {
90 match self {
91 ToleranceKind::TrimEnd => "trailing whitespace",
92 ToleranceKind::RemoveLineNumbers => "line-number prefixes",
93 ToleranceKind::NormalizeCommonMistakes => "smart-quote/dash normalization",
94 ToleranceKind::IgnoreIndentation => "indentation",
95 ToleranceKind::IgnoreWhitespace => "all whitespace",
96 }
97 }
98}
99
100fn collect_tolerances(replacements: &[Replacement]) -> Vec<ToleranceKind> {
103 let mut out: Vec<ToleranceKind> = Vec::new();
104 for replacement in replacements {
105 for &tolerance in &replacement.tolerances {
106 if !out.contains(&tolerance) {
107 out.push(tolerance);
108 }
109 }
110 }
111 out
112}
113
114#[derive(Debug, Clone, PartialEq, Eq)]
115struct MatchCandidate {
116 start: usize,
117 end: usize,
118 score: usize,
119 tolerances: Vec<ToleranceKind>,
120 replace: Vec<String>,
121}
122
123#[derive(Debug, Clone, PartialEq, Eq)]
124struct Replacement {
125 start: usize,
126 end: usize,
127 replace: Vec<String>,
128 tolerances: Vec<ToleranceKind>,
131}
132
133fn parse_blocks(content: &str) -> Result<Vec<SearchReplaceBlock>> {
134 let mut blocks = Vec::new();
135 let lines: Vec<&str> = content.lines().collect();
136 let mut i = 0;
137
138 while i < lines.len() {
139 let anchors = search_marker()?.captures(lines[i]).map(|caps| {
140 (
141 caps.get(1).and_then(|m| m.as_str().parse::<usize>().ok()),
142 caps.get(2).and_then(|m| m.as_str().parse::<usize>().ok()),
143 )
144 });
145 if let Some((anchor_start, anchor_end)) = anchors {
146 let line_num = i + 1;
147 i += 1;
148 let mut search_lines = Vec::new();
149 while i < lines.len() && !divider_marker()?.is_match(lines[i]) {
150 if search_marker()?.is_match(lines[i]) || replace_marker()?.is_match(lines[i]) {
151 return Err(WinxError::SearchReplaceSyntaxError(format!(
152 "Line {}: stray marker in SEARCH block",
153 i + 1
154 )));
155 }
156 search_lines.push(lines[i]);
157 i += 1;
158 }
159
160 if i >= lines.len() {
161 return Err(WinxError::SearchReplaceSyntaxError(format!(
162 "Line {line_num}: unclosed SEARCH block - missing ======= marker"
163 )));
164 }
165
166 if search_lines.is_empty() {
167 return Err(WinxError::SearchReplaceSyntaxError(format!(
168 "Line {line_num}: SEARCH block cannot be empty"
169 )));
170 }
171
172 i += 1;
173 let mut replace_lines = Vec::new();
174 while i < lines.len() && !replace_marker()?.is_match(lines[i]) {
175 if search_marker()?.is_match(lines[i]) || divider_marker()?.is_match(lines[i]) {
176 return Err(WinxError::SearchReplaceSyntaxError(format!(
177 "Line {}: stray marker in REPLACE block",
178 i + 1
179 )));
180 }
181 replace_lines.push(lines[i]);
182 i += 1;
183 }
184
185 if i >= lines.len() {
186 return Err(WinxError::SearchReplaceSyntaxError(format!(
187 "Line {line_num}: unclosed block - missing REPLACE marker"
188 )));
189 }
190
191 blocks.push(SearchReplaceBlock {
192 search: search_lines.into_iter().map(str::to_string).collect(),
193 replace: replace_lines.into_iter().map(str::to_string).collect(),
194 anchor_start,
195 anchor_end,
196 });
197 } else if divider_marker()?.is_match(lines[i]) || replace_marker()?.is_match(lines[i]) {
198 return Err(WinxError::SearchReplaceSyntaxError(format!(
199 "Line {}: stray marker outside block",
200 i + 1
201 )));
202 }
203 i += 1;
204 }
205
206 if blocks.is_empty() {
207 return Err(WinxError::SearchReplaceSyntaxError("No valid blocks found".to_string()));
208 }
209
210 Ok(blocks)
211}
212
213fn apply_blocks_with_unescape_retry(
217 original: &str,
218 raw: &str,
219) -> Result<(String, Vec<ToleranceKind>)> {
220 let blocks = parse_blocks(raw)?;
221 match apply_blocks(original, &blocks) {
222 Ok(result) => Ok(result),
223 Err(first_err) => {
224 let unescaped = raw.replace("\\\"", "\"");
225 if unescaped == raw {
226 return Err(first_err);
227 }
228 let retry_blocks = parse_blocks(&unescaped).map_err(|_| first_err)?;
229 apply_blocks(original, &retry_blocks)
230 }
231 }
232}
233
234fn apply_blocks(
235 content: &str,
236 blocks: &[SearchReplaceBlock],
237) -> Result<(String, Vec<ToleranceKind>)> {
238 let uses_crlf = content.contains("\r\n");
244 let normalized;
245 let content_lf: &str = if uses_crlf {
246 normalized = content.replace("\r\n", "\n");
247 &normalized
248 } else {
249 content
250 };
251
252 let original_lines = split_lines(content_lf);
253 let (edited, tolerances) =
254 apply_blocks_ordered(&original_lines, blocks).or_else(|ordered_error| {
255 if blocks.len() == 1 {
256 Err(ordered_error)
257 } else {
258 apply_blocks_individually(&original_lines, blocks)
259 }
260 })?;
261
262 let joined = edited.join("\n");
263 let content_out = if uses_crlf { joined.replace('\n', "\r\n") } else { joined };
264 Ok((content_out, tolerances))
265}
266
267fn split_lines(content: &str) -> Vec<String> {
268 content.split('\n').map(str::to_string).collect()
269}
270
271pub(crate) fn ensure_parent_dirs(path: &Path) -> Result<()> {
295 if let Some(parent) = path.parent() {
296 if !parent.as_os_str().is_empty() && !parent.exists() {
297 fs::create_dir_all(parent).map_err(|e| WinxError::FileAccessError {
298 path: path.to_path_buf(),
299 message: format!("Failed to create parent directories: {e}"),
300 })?;
301 }
302 }
303 Ok(())
304}
305
306pub(crate) fn write_no_follow(path: &Path, content: &[u8]) -> std::io::Result<()> {
307 use std::io::Error;
308
309 let parent = path.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new("."));
310 let mut tmp =
314 tempfile::Builder::new().prefix(".winx-tmp-").tempfile_in(parent).map_err(|e| {
315 Error::new(e.kind(), format!("create temp file in {}: {e}", parent.display()))
316 })?;
317
318 if let Ok(meta) = fs::symlink_metadata(path) {
321 if meta.file_type().is_file() {
322 let _ = tmp.as_file().set_permissions(meta.permissions());
323 }
324 }
325
326 tmp.write_all(content)?;
327 tmp.as_file().sync_all()?;
328 tmp.persist(path).map_err(|e| e.error)?;
331 Ok(())
332}
333
334const MAX_CANDIDATES_PER_BLOCK: usize = 64;
337const MAX_SEARCH_NODES: u32 = 50_000;
340const MAX_TOTAL_TOLERANCE_SCORE: usize = 1000;
344
345fn apply_blocks_ordered(
346 lines: &[String],
347 blocks: &[SearchReplaceBlock],
348) -> Result<(Vec<String>, Vec<ToleranceKind>)> {
349 let mut budget = MAX_SEARCH_NODES;
350 let (score, replacements) = best_ordered_replacements(lines, blocks, 0, 0, &mut budget)?;
351 if score > MAX_TOTAL_TOLERANCE_SCORE {
352 return Err(WinxError::SearchBlockNotFound(format!(
353 "SEARCH blocks only matched very loosely (tolerance score {score} over limit \
354 {MAX_TOTAL_TOLERANCE_SCORE}). The file likely changed since you read it — re-read it \
355 and make the SEARCH text match the current content exactly."
356 )));
357 }
358 let tolerances = collect_tolerances(&replacements);
359 Ok((apply_replacements(lines, &replacements), tolerances))
360}
361
362fn best_ordered_replacements(
363 lines: &[String],
364 blocks: &[SearchReplaceBlock],
365 block_index: usize,
366 offset: usize,
367 budget: &mut u32,
368) -> Result<(usize, Vec<Replacement>)> {
369 if block_index >= blocks.len() {
370 return Ok((0, Vec::new()));
371 }
372 if *budget == 0 {
373 return Err(WinxError::SearchBlockNotFound(
374 "Search/replace is too ambiguous (too many candidate combinations). Add more \
375 surrounding context so each SEARCH block matches a unique location."
376 .to_string(),
377 ));
378 }
379 *budget -= 1;
380
381 let block = &blocks[block_index];
382 let candidates = find_candidates(lines, block, offset);
383 if candidates.is_empty() {
384 return Err(not_found_error(block, lines, offset));
385 }
386 if candidates.len() > MAX_CANDIDATES_PER_BLOCK {
387 return Err(WinxError::SearchBlockNotFound(format!(
388 "A SEARCH block matches {} locations (limit {MAX_CANDIDATES_PER_BLOCK}); add more \
389 surrounding context to make it unique:\n{}",
390 candidates.len(),
391 block.search.join("\n")
392 )));
393 }
394
395 let mut valid_paths = Vec::new();
396 for candidate in candidates {
397 if let Ok((tail_score, mut tail)) =
398 best_ordered_replacements(lines, blocks, block_index + 1, candidate.end, budget)
399 {
400 let mut path = vec![Replacement {
401 start: candidate.start,
402 end: candidate.end,
403 replace: candidate.replace,
404 tolerances: candidate.tolerances,
405 }];
406 path.append(&mut tail);
407 valid_paths.push((candidate.score + tail_score, path));
408 }
409 }
410
411 select_unique_best_path(block, valid_paths)
412}
413
414fn select_unique_best_path(
415 block: &SearchReplaceBlock,
416 paths: Vec<(usize, Vec<Replacement>)>,
417) -> Result<(usize, Vec<Replacement>)> {
418 let Some(best_score) = paths.iter().map(|(score, _)| *score).min() else {
419 return Err(WinxError::SearchBlockNotFound(format!(
420 "Block not found: {}",
421 block.search.join("\n")
422 )));
423 };
424
425 let best_paths: Vec<(usize, Vec<Replacement>)> =
426 paths.into_iter().filter(|(score, _)| *score == best_score).collect();
427
428 if best_paths.len() == 1 {
429 return best_paths.into_iter().next().ok_or_else(|| {
430 WinxError::SearchBlockNotFound(format!("Block not found: {}", block.search.join("\n")))
431 });
432 }
433
434 let ranges = best_paths
435 .iter()
436 .filter_map(|(_, reps)| reps.first().map(|r| format!("{}-{}", r.start + 1, r.end)))
437 .collect::<Vec<_>>()
438 .join(", ");
439 Err(WinxError::SearchBlockAmbiguous {
440 block_content: block.search.join("\n"),
441 match_count: best_paths.len(),
442 suggestions: vec![
443 format!("Equally-good matches at lines: {ranges}"),
444 "Add more context before or after this block to make it unique.".to_string(),
445 ],
446 })
447}
448
449fn apply_blocks_individually(
450 lines: &[String],
451 blocks: &[SearchReplaceBlock],
452) -> Result<(Vec<String>, Vec<ToleranceKind>)> {
453 let mut running_lines = lines.to_vec();
454 let mut total_score = 0usize;
455 let mut tolerances: Vec<ToleranceKind> = Vec::new();
456 for block in blocks {
457 let candidate = select_unique_candidate(block, find_candidates(&running_lines, block, 0))?;
458 total_score = total_score.saturating_add(candidate.score);
462 if total_score > MAX_TOTAL_TOLERANCE_SCORE {
463 return Err(WinxError::SearchBlockNotFound(format!(
464 "SEARCH blocks only matched very loosely (tolerance score {total_score} over \
465 limit {MAX_TOTAL_TOLERANCE_SCORE}). The file likely changed since you read it — \
466 re-read it and make the SEARCH text match the current content exactly."
467 )));
468 }
469 for &tolerance in &candidate.tolerances {
470 if !tolerances.contains(&tolerance) {
471 tolerances.push(tolerance);
472 }
473 }
474 running_lines = apply_replacements(
475 &running_lines,
476 &[Replacement {
477 start: candidate.start,
478 end: candidate.end,
479 replace: candidate.replace,
480 tolerances: Vec::new(),
481 }],
482 );
483 }
484 Ok((running_lines, tolerances))
485}
486
487fn select_unique_candidate(
488 block: &SearchReplaceBlock,
489 candidates: Vec<MatchCandidate>,
490) -> Result<MatchCandidate> {
491 if candidates.is_empty() {
492 return Err(WinxError::SearchBlockNotFound(format!(
493 "Block not found: {}",
494 block.search.join("\n")
495 )));
496 }
497
498 let best_score = candidates.iter().map(|candidate| candidate.score).min().unwrap_or(0);
499 let best: Vec<MatchCandidate> =
500 candidates.into_iter().filter(|candidate| candidate.score == best_score).collect();
501
502 if best.len() == 1 {
503 return best.into_iter().next().ok_or_else(|| {
504 WinxError::SearchBlockNotFound(format!("Block not found: {}", block.search.join("\n")))
505 });
506 }
507
508 let ranges = best
509 .iter()
510 .map(|candidate| format!("{}-{}", candidate.start + 1, candidate.end))
511 .collect::<Vec<_>>()
512 .join(", ");
513 Err(WinxError::SearchBlockAmbiguous {
514 block_content: block.search.join("\n"),
515 match_count: best.len(),
516 suggestions: vec![
517 format!("Equally-good matches at lines: {ranges}"),
518 "Add more context to make the search block unique.".to_string(),
519 ],
520 })
521}
522
523fn apply_replacements(lines: &[String], replacements: &[Replacement]) -> Vec<String> {
524 let mut edited = Vec::new();
525 let mut cursor = 0;
526
527 for replacement in replacements {
528 edited.extend_from_slice(&lines[cursor..replacement.start]);
529 edited.extend(replacement.replace.clone());
530 cursor = replacement.end;
531 }
532
533 edited.extend_from_slice(&lines[cursor..]);
534 edited
535}
536
537fn find_candidates(
538 lines: &[String],
539 block: &SearchReplaceBlock,
540 offset: usize,
541) -> Vec<MatchCandidate> {
542 let mut candidates = find_contiguous_candidates(lines, block, offset, false);
543 if candidates.is_empty() {
544 candidates = find_single_line_substring_candidates(lines, block, offset);
545 }
546 if candidates.is_empty() {
547 candidates = find_contiguous_candidates(lines, block, offset, true);
548 }
549 narrow_to_anchor(candidates, block)
550}
551
552fn narrow_to_anchor(
557 candidates: Vec<MatchCandidate>,
558 block: &SearchReplaceBlock,
559) -> Vec<MatchCandidate> {
560 let Some(start) = block.anchor_start else {
561 return candidates;
562 };
563 let lo = start.saturating_sub(1); let hi = block.anchor_end.unwrap_or(start).saturating_sub(1);
565 let anchored: Vec<MatchCandidate> =
566 candidates.iter().filter(|c| c.start >= lo && c.start <= hi).cloned().collect();
567 if anchored.is_empty() {
568 candidates
569 } else {
570 anchored
571 }
572}
573
574fn find_single_line_substring_candidates(
575 lines: &[String],
576 block: &SearchReplaceBlock,
577 offset: usize,
578) -> Vec<MatchCandidate> {
579 if block.search.len() != 1 {
580 return Vec::new();
581 }
582
583 let search = &block.search[0];
584 if search.is_empty() {
585 return Vec::new();
586 }
587
588 let replace = block.replace.join("\n");
589 lines
590 .iter()
591 .enumerate()
592 .skip(offset)
593 .flat_map(|(index, line)| {
594 let replace = replace.clone();
595 line.match_indices(search).map(move |(byte_index, _)| {
596 let mut replaced_line = line.clone();
597 replaced_line.replace_range(byte_index..byte_index + search.len(), &replace);
598 MatchCandidate {
599 start: index,
600 end: index + 1,
601 score: 0,
602 tolerances: Vec::new(),
603 replace: split_lines(&replaced_line),
604 }
605 })
606 })
607 .collect()
608}
609
610fn find_contiguous_candidates(
611 lines: &[String],
612 block: &SearchReplaceBlock,
613 offset: usize,
614 ignore_empty_lines: bool,
615) -> Vec<MatchCandidate> {
616 let search_lines = if ignore_empty_lines {
617 block.search.iter().filter(|line| !line.trim().is_empty()).cloned().collect()
618 } else {
619 block.search.clone()
620 };
621
622 if search_lines.is_empty() || lines.len().saturating_sub(offset) < search_lines.len() {
623 return Vec::new();
624 }
625
626 if ignore_empty_lines {
627 return find_empty_line_tolerant_candidates(lines, block, offset, &search_lines);
628 }
629
630 let max_start = lines.len() - search_lines.len();
631 (offset..=max_start)
632 .filter_map(|start| {
633 let end = start + search_lines.len();
634 let actual_lines: Vec<&String> = lines[start..end].iter().collect();
635 match_candidate(lines, &actual_lines, &search_lines, block, start, end, false)
636 })
637 .collect()
638}
639
640fn find_empty_line_tolerant_candidates(
641 lines: &[String],
642 block: &SearchReplaceBlock,
643 offset: usize,
644 search_lines: &[String],
645) -> Vec<MatchCandidate> {
646 let compact_lines: Vec<(usize, &String)> =
647 lines.iter().enumerate().skip(offset).filter(|(_, line)| !line.trim().is_empty()).collect();
648
649 if compact_lines.len() < search_lines.len() {
650 return Vec::new();
651 }
652
653 let max_start = compact_lines.len() - search_lines.len();
654 (0..=max_start)
655 .filter_map(|compact_start| {
656 let compact_end = compact_start + search_lines.len();
657 let start = compact_lines[compact_start].0;
658 let end = compact_lines[compact_end - 1].0 + 1;
659 let actual_lines: Vec<&String> =
660 compact_lines[compact_start..compact_end].iter().map(|(_, line)| *line).collect();
661 match_candidate(lines, &actual_lines, search_lines, block, start, end, true)
662 })
663 .collect()
664}
665
666fn match_candidate(
667 lines: &[String],
668 actual_lines: &[&String],
669 search_lines: &[String],
670 block: &SearchReplaceBlock,
671 start: usize,
672 end: usize,
673 ignore_empty_lines: bool,
674) -> Option<MatchCandidate> {
675 let mut tolerances = Vec::new();
676 let mut score = 0;
677
678 for (actual, expected) in actual_lines.iter().zip(search_lines) {
679 let line_match = matching_tolerance(actual, expected)?;
680 if let LineMatch::Tolerated(tolerance) = line_match {
681 score += tolerance.score();
682 if !tolerances.contains(&tolerance) {
683 tolerances.push(tolerance);
684 }
685 }
686 }
687
688 let mut replace = if ignore_empty_lines {
689 trim_empty_edge_lines(&block.replace)
690 } else {
691 block.replace.clone()
692 };
693 if tolerances.contains(&ToleranceKind::RemoveLineNumbers) {
694 replace = replace.into_iter().map(|line| remove_leading_line_number(&line)).collect();
695 }
696 if tolerances.contains(&ToleranceKind::IgnoreIndentation) {
697 let matched = &lines[start..end];
698 replace = fix_indentation(matched, search_lines, &replace);
699 }
700
701 Some(MatchCandidate { start, end, score, tolerances, replace })
702}
703
704fn matching_tolerance(actual: &str, expected: &str) -> Option<LineMatch> {
705 if actual == expected {
706 return Some(LineMatch::Exact);
707 }
708 if actual.trim_end() == expected.trim_end() {
709 return Some(LineMatch::Tolerated(ToleranceKind::TrimEnd));
710 }
711 if actual.trim_start() == expected.trim_start() {
712 return Some(LineMatch::Tolerated(ToleranceKind::IgnoreIndentation));
713 }
714 if remove_leading_line_number(actual) == remove_leading_line_number(expected) {
715 return Some(LineMatch::Tolerated(ToleranceKind::RemoveLineNumbers));
716 }
717 if normalize_common_mistakes(actual) == normalize_common_mistakes(expected) {
718 return Some(LineMatch::Tolerated(ToleranceKind::NormalizeCommonMistakes));
719 }
720 if remove_ascii_whitespace(actual) == remove_ascii_whitespace(expected) {
721 return Some(LineMatch::Tolerated(ToleranceKind::IgnoreWhitespace));
722 }
723 None
724}
725
726fn remove_ascii_whitespace(value: &str) -> String {
727 value.chars().filter(|c| !c.is_whitespace()).collect()
728}
729
730fn remove_leading_line_number(value: &str) -> String {
731 value
732 .split_once(' ')
733 .filter(|(prefix, _)| !prefix.is_empty() && prefix.chars().all(|c| c.is_ascii_digit()))
734 .map_or_else(|| value.trim_end().to_string(), |(_, rest)| rest.trim_end().to_string())
735}
736
737fn normalize_common_mistakes(value: &str) -> String {
738 let mut normalized = String::with_capacity(value.len());
739 for character in value.chars() {
740 match character {
741 '\u{2018}' | '\u{2019}' | '\u{201b}' | '\u{2032}' => normalized.push('\''),
742 '\u{201a}' => normalized.push(','),
743 '\u{201c}' | '\u{201d}' | '\u{201f}' | '\u{2033}' => normalized.push('"'),
744 '\u{2039}' => normalized.push('<'),
745 '\u{203a}' => normalized.push('>'),
746 '\u{2010}' | '\u{2011}' | '\u{2012}' | '\u{2013}' | '\u{2014}' | '\u{2015}'
747 | '\u{2212}' => normalized.push('-'),
748 '\u{2026}' => normalized.push_str("..."),
749 other => normalized.push(other),
750 }
751 }
752 normalized.trim_end().to_string()
753}
754
755fn fix_indentation(
756 matched_lines: &[String],
757 searched_lines: &[String],
758 replaced_lines: &[String],
759) -> Vec<String> {
760 if matched_lines.is_empty() || searched_lines.is_empty() || replaced_lines.is_empty() {
761 return replaced_lines.to_vec();
762 }
763
764 let matched_indents = non_empty_indents(matched_lines);
765 let searched_indents = non_empty_indents(searched_lines);
766 if matched_indents.len() != searched_indents.len() || matched_indents.is_empty() {
767 return replaced_lines.to_vec();
768 }
769
770 let diffs: Vec<isize> = matched_indents
773 .iter()
774 .zip(&searched_indents)
775 .map(|(matched, searched)| {
776 searched.chars().count() as isize - matched.chars().count() as isize
777 })
778 .collect();
779 let Some(&first_diff) = diffs.first() else {
780 return replaced_lines.to_vec();
781 };
782 if first_diff == 0 || !diffs.iter().all(|diff| *diff == first_diff) {
783 return replaced_lines.to_vec();
784 }
785
786 adjust_replacement_indentation(replaced_lines, &matched_indents[0], first_diff)
787}
788
789fn non_empty_indents(lines: &[String]) -> Vec<String> {
790 lines
791 .iter()
792 .filter(|line| !line.trim().is_empty())
793 .map(|line| line.chars().take_while(|c| c.is_whitespace()).collect())
794 .collect()
795}
796
797fn adjust_replacement_indentation(
798 replaced_lines: &[String],
799 matched_indent: &str,
800 diff: isize,
801) -> Vec<String> {
802 if diff < 0 {
806 let prefix_len = usize::try_from(-diff).unwrap_or(0);
807 let prefix: String = matched_indent.chars().take(prefix_len).collect();
808 return replaced_lines.iter().map(|line| format!("{prefix}{line}")).collect();
809 }
810
811 let remove_len = usize::try_from(diff).unwrap_or(0);
812 if !replaced_lines.iter().all(|line| removable_indent(line, remove_len)) {
813 return replaced_lines.to_vec();
814 }
815 replaced_lines.iter().map(|line| line.chars().skip(remove_len).collect()).collect()
816}
817
818fn removable_indent(line: &str, remove_len: usize) -> bool {
819 line.chars().take(remove_len).filter(|c| c.is_whitespace()).count() == remove_len
820}
821
822fn trim_empty_edge_lines(lines: &[String]) -> Vec<String> {
823 let Some(first) = lines.iter().position(|line| !line.trim().is_empty()) else {
824 return Vec::new();
825 };
826 let last = lines.iter().rposition(|line| !line.trim().is_empty()).unwrap_or(first);
827 lines[first..=last].to_vec()
828}
829
830const SNIPPET_CONTEXT_LINES: usize = 10;
833
834fn not_found_error(block: &SearchReplaceBlock, lines: &[String], offset: usize) -> WinxError {
835 let (snippet, similarity) = closest_snippet(lines, offset, &block.search);
836 WinxError::SearchBlockNotFound(format!(
837 "Block not found in file. The SEARCH block below didn't match anywhere:\n{}\n\n\
838 Closest matching context in the file ({:.0}% similar; lines marked ~ are the ones that \
839 diverged from your SEARCH — re-read the file and copy the text exactly):\n{}",
840 block.search.join("\n"),
841 similarity * 100.0,
842 snippet
843 ))
844}
845
846fn closest_snippet(lines: &[String], offset: usize, search: &[String]) -> (String, f64) {
850 let window = search.len().max(1);
851 if lines.is_empty() || offset >= lines.len() || window > lines.len() {
857 return (String::new(), 0.0);
858 }
859
860 let max_start = lines.len().saturating_sub(window);
861 let mut best_start = offset;
862 let mut best_score = f64::MIN;
863 for start in offset..=max_start {
864 let score = snippet_similarity(&lines[start..(start + window)], search);
865 if score > best_score {
866 best_score = score;
867 best_start = start;
868 }
869 }
870
871 let context_start = best_start.saturating_sub(SNIPPET_CONTEXT_LINES);
874 let context_end = (best_start + window + SNIPPET_CONTEXT_LINES).min(lines.len());
875 let snippet = lines[context_start..context_end]
876 .iter()
877 .enumerate()
878 .map(|(index, line)| {
879 let abs = context_start + index;
880 let marker = if abs >= best_start && abs < best_start + window {
884 let search_line = &search[abs - best_start];
885 if strsim::normalized_levenshtein(line.trim(), search_line.trim()) < 0.6 {
886 '~'
887 } else {
888 ' '
889 }
890 } else {
891 ' '
892 };
893 format!("{:>6} {marker} {line}", abs + 1)
894 })
895 .collect::<Vec<_>>()
896 .join("\n");
897 let similarity = (best_score / window as f64).clamp(0.0, 1.0);
900 (snippet, similarity)
901}
902
903fn snippet_similarity(candidate: &[String], search: &[String]) -> f64 {
904 candidate
905 .iter()
906 .zip(search)
907 .map(|(candidate_line, search_line)| {
908 strsim::normalized_levenshtein(candidate_line.trim(), search_line.trim())
909 })
910 .sum::<f64>()
911 - candidate.len().abs_diff(search.len()) as f64
912}
913
914fn uses_search_replace(percentage_to_change: u32, blocks: &str) -> bool {
915 if percentage_to_change <= 50 {
916 return true;
917 }
918
919 let first_content_line = blocks.trim_start().lines().next();
920 first_content_line.is_some_and(|line| search_marker().is_ok_and(|marker| marker.is_match(line)))
921}
922
923pub(crate) fn hash_content(content: &str) -> String {
924 let digest = Sha256::digest(content.as_bytes());
925 digest.iter().fold(String::with_capacity(digest.len() * 2), |mut hash, byte| {
926 let _ = write!(hash, "{byte:02x}");
927 hash
928 })
929}
930
931fn format_unread_ranges(whitelist: &FileWhitelistData) -> String {
932 whitelist
933 .get_unread_ranges()
934 .into_iter()
935 .map(|(start, end)| if start == end { start.to_string() } else { format!("{start}-{end}") })
936 .collect::<Vec<_>>()
937 .join(", ")
938}
939
940#[instrument(level = "info", skip(bash_state_arc, file_write_or_edit))]
941pub async fn handle_tool_call(
942 bash_state_arc: &Arc<Mutex<Option<BashState>>>,
943 file_write_or_edit: FileWriteOrEdit,
944) -> Result<String> {
945 let mut bash_state_guard = bash_state_arc.lock().await;
946 let bash_state = bash_state_guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
947
948 let thread_id = normalize_thread_id(&file_write_or_edit.thread_id);
949 if thread_id != bash_state.current_thread_id {
950 return Err(WinxError::ThreadIdMismatch(thread_id));
951 }
952
953 let planned = plan_edit(
954 bash_state,
955 &file_write_or_edit.file_path,
956 file_write_or_edit.percentage_to_change,
957 &file_write_or_edit.text_or_search_replace_blocks,
958 )?;
959 commit_edit(bash_state, planned)
960}
961
962pub(crate) struct PlannedEdit {
967 path: PathBuf,
968 file_path_str: String,
969 action: &'static str,
971 new_content: String,
972 previous: Option<String>,
974 tolerances: Vec<ToleranceKind>,
975 uses_search_replace: bool,
976}
977
978impl PlannedEdit {
979 pub(crate) fn target(&self) -> &str {
981 &self.file_path_str
982 }
983}
984
985pub(crate) fn plan_edit(
991 bash_state: &BashState,
992 file_path: &str,
993 percentage_to_change: u32,
994 blocks: &str,
995) -> Result<PlannedEdit> {
996 let expanded_path = expand_user(file_path);
997 let path = if Path::new(&expanded_path).is_absolute() {
998 PathBuf::from(&expanded_path)
999 } else {
1000 bash_state.cwd.join(&expanded_path)
1001 };
1002
1003 let path = validate_path_in_workspace(&path, &bash_state.workspace_root)
1004 .map_err(|e| WinxError::PathSecurityError { path: path.clone(), message: e.to_string() })?;
1005
1006 let file_path_str = path.to_string_lossy().to_string();
1007
1008 let uses_search_replace = uses_search_replace(percentage_to_change, blocks);
1009 let operation_allowed = if uses_search_replace {
1010 bash_state.is_file_edit_allowed(&file_path_str)
1011 } else {
1012 bash_state.is_file_write_allowed(&file_path_str)
1013 };
1014
1015 if !operation_allowed {
1016 return Err(WinxError::FileAccessError {
1017 path,
1018 message: "File operation not allowed in current mode.".to_string(),
1019 });
1020 }
1021
1022 let pre_write_content: Option<String> = if path.exists() {
1027 Some(fs::read_to_string(&path).map_err(|e| WinxError::FileAccessError {
1028 path: path.clone(),
1029 message: format!("reading existing file before edit: {e}"),
1030 })?)
1031 } else {
1032 None
1033 };
1034
1035 if let Some(original_content) = pre_write_content.as_deref() {
1036 let whitelist =
1037 bash_state.whitelist_for_overwrite.get(&file_path_str).ok_or_else(|| {
1038 WinxError::FileAccessError {
1039 path: path.clone(),
1040 message: format!(
1041 "This file exists but hasn't been read in this session. \
1042 Call ReadFiles on {file_path_str} first, then retry the edit \
1043 (winx requires a fresh read so edits are never applied blind)."
1044 ),
1045 }
1046 })?;
1047 let current_hash = hash_content(original_content);
1048 if whitelist.file_hash != current_hash {
1049 return Err(WinxError::FileAccessError {
1050 path,
1051 message: format!(
1052 "{file_path_str} changed on disk since you last read it. \
1053 Call ReadFiles again to get the current content, then retry the edit."
1054 ),
1055 });
1056 }
1057 if !uses_search_replace && !whitelist.is_read_enough() {
1058 return Err(WinxError::FileAccessError {
1059 path,
1060 message: format!(
1061 "Read more of the file before overwriting. Unread line ranges: {}",
1062 format_unread_ranges(whitelist)
1063 ),
1064 });
1065 }
1066 }
1067
1068 let (action, new_content, tolerances) = if uses_search_replace {
1069 let original_content = pre_write_content.as_deref().unwrap_or_default();
1072 let (new_content, tolerances) = apply_blocks_with_unescape_retry(original_content, blocks)?;
1073 ("edited", new_content, tolerances)
1074 } else {
1075 ("wrote", blocks.to_string(), Vec::new())
1076 };
1077
1078 Ok(PlannedEdit {
1079 path,
1080 file_path_str,
1081 action,
1082 new_content,
1083 previous: pre_write_content,
1084 tolerances,
1085 uses_search_replace,
1086 })
1087}
1088
1089pub(crate) fn commit_edit(bash_state: &mut BashState, planned: PlannedEdit) -> Result<String> {
1093 let PlannedEdit {
1094 path,
1095 file_path_str,
1096 action,
1097 new_content,
1098 previous,
1099 tolerances,
1100 uses_search_replace,
1101 } = planned;
1102
1103 ensure_parent_dirs(&path)?;
1105 write_no_follow(&path, new_content.as_bytes())?;
1106
1107 if let Some(prior_content) = &previous {
1111 let prior_whitelist = bash_state.whitelist_for_overwrite.get(&file_path_str).cloned();
1112 bash_state.push_edit_checkpoint(crate::state::bash_state::EditCheckpoint {
1113 file_path_str: file_path_str.clone(),
1114 path: path.clone(),
1115 prior_content: prior_content.clone(),
1116 prior_whitelist,
1117 });
1118 }
1119
1120 let result = operation_result(
1121 action,
1122 &file_path_str,
1123 &path,
1124 &new_content,
1125 &tolerances,
1126 previous.as_deref(),
1127 );
1128 refresh_whitelist_and_stats(
1129 bash_state,
1130 file_path_str,
1131 &path,
1132 &new_content,
1133 uses_search_replace,
1134 );
1135 Ok(result)
1136}
1137
1138fn refresh_whitelist_and_stats(
1143 bash_state: &mut BashState,
1144 file_path_str: String,
1145 path: &Path,
1146 new_content: &str,
1147 uses_search_replace: bool,
1148) {
1149 let hash = hash_content(new_content);
1156 let total_lines = new_content.lines().count();
1157 bash_state
1158 .whitelist_for_overwrite
1159 .insert(file_path_str, FileWhitelistData::new(hash, vec![(1, total_lines)], total_lines));
1160
1161 let (kind, stats) = if uses_search_replace {
1162 ("edit", crate::utils::workspace_stats::record_edit(&bash_state.workspace_root, path))
1163 } else {
1164 ("write", crate::utils::workspace_stats::record_write(&bash_state.workspace_root, path))
1165 };
1166 if let Err(e) = stats {
1167 debug!("failed to record {kind} stats: {e}");
1168 }
1169}
1170
1171const DIFF_CONTEXT_LINES: usize = 3;
1173const MAX_DIFF_LINES: usize = 200;
1177const MAX_DIFF_INPUT_BYTES: usize = 512 * 1024;
1180
1181fn change_summary(previous: &str, current: &str) -> Option<String> {
1187 if previous == current {
1188 return None;
1189 }
1190 if previous.len().saturating_add(current.len()) > MAX_DIFF_INPUT_BYTES {
1194 let (before, after) = (previous.lines().count(), current.lines().count());
1195 return Some(format!("Changes: {before} -> {after} lines (file too large to diff)"));
1196 }
1197 let diff = TextDiff::from_lines(previous, current);
1198 let (mut added, mut removed) = (0usize, 0usize);
1199 for change in diff.iter_all_changes() {
1200 match change.tag() {
1201 ChangeTag::Insert => added += 1,
1202 ChangeTag::Delete => removed += 1,
1203 ChangeTag::Equal => {}
1204 }
1205 }
1206 let rendered = diff.unified_diff().context_radius(DIFF_CONTEXT_LINES).to_string();
1207 if rendered.lines().count() > MAX_DIFF_LINES {
1208 return Some(format!("Changes: +{added} -{removed} lines (diff too large to show)"));
1209 }
1210 Some(format!("Changes (+{added} -{removed}):\n{}", rendered.trim_end()))
1211}
1212
1213fn operation_result(
1214 action: &str,
1215 file_path: &str,
1216 path: &Path,
1217 content: &str,
1218 tolerances: &[ToleranceKind],
1219 previous: Option<&str>,
1220) -> String {
1221 let mut result = format!("Successfully {action} {file_path}");
1222 if !tolerances.is_empty() {
1223 let names = tolerances.iter().map(|t| t.display_name()).collect::<Vec<_>>().join(", ");
1226 let _ = write!(
1227 result,
1228 "\n\nNote: matched after tolerating {names} differences — your SEARCH text didn't \
1229 match the file exactly. Re-read the file if you expected an exact match."
1230 );
1231 }
1232 if let Some(diff) = previous.and_then(|prev| change_summary(prev, content)) {
1235 let _ = write!(result, "\n\n{diff}");
1236 }
1237 if let Some(warning) = crate::utils::syntax::syntax_warning(path, content) {
1238 let _ = write!(result, "\n\n{warning}");
1239 }
1240 result
1241}
1242
1243#[cfg(test)]
1244mod indentation_tests {
1245 #![allow(clippy::expect_used)]
1246 use super::*;
1247
1248 #[test]
1253 fn closest_snippet_search_longer_than_file_does_not_panic() {
1254 let lines = vec!["the only line".to_string()];
1257 let search = vec!["a".to_string(), "b".to_string(), "c".to_string()];
1258 let (snippet, similarity) = closest_snippet(&lines, 0, &search);
1259 assert!(snippet.is_empty());
1260 assert!(similarity.abs() < f64::EPSILON, "expected 0.0 similarity, got {similarity}");
1261 }
1262
1263 #[test]
1264 fn closest_snippet_normal_case_still_finds_a_window() {
1265 let lines = vec!["alpha".to_string(), "beta".to_string(), "gamma".to_string()];
1267 let search = vec!["beta".to_string()];
1268 let (snippet, similarity) = closest_snippet(&lines, 0, &search);
1269 assert!(snippet.contains("beta"));
1270 assert!(similarity > 0.0);
1271 }
1272
1273 #[test]
1274 fn fix_indentation_adds_multibyte_indent_without_panic() {
1275 let matched = vec!["\u{3000}\u{3000}x".to_string()];
1277 let searched = vec!["\u{3000}x".to_string()];
1278 let replaced = vec!["y".to_string()];
1279 let out = fix_indentation(&matched, &searched, &replaced);
1280 assert_eq!(out, vec!["\u{3000}y".to_string()]);
1281 }
1282
1283 #[test]
1284 fn fix_indentation_removes_multibyte_indent_without_panic() {
1285 let matched = vec!["\u{3000}x".to_string()];
1287 let searched = vec!["\u{3000}\u{3000}x".to_string()];
1288 let replaced = vec!["\u{3000}y".to_string()];
1289 let out = fix_indentation(&matched, &searched, &replaced);
1290 assert_eq!(out, vec!["y".to_string()]);
1291 }
1292
1293 #[test]
1294 fn apply_blocks_preserves_crlf_endings() -> Result<()> {
1295 let content = "line one\r\nline two\r\nline three\r\n";
1298 let block = SearchReplaceBlock {
1299 search: vec!["line two".to_string()],
1300 replace: vec!["line TWO".to_string()],
1301 ..Default::default()
1302 };
1303 let (out, _) = apply_blocks(content, &[block])?;
1304 assert_eq!(out, "line one\r\nline TWO\r\nline three\r\n");
1305 Ok(())
1306 }
1307
1308 #[test]
1309 fn apply_blocks_leaves_lf_files_as_lf() -> Result<()> {
1310 let content = "a\nb\nc\n";
1311 let block = SearchReplaceBlock {
1312 search: vec!["b".to_string()],
1313 replace: vec!["B".to_string()],
1314 ..Default::default()
1315 };
1316 let (out, _) = apply_blocks(content, &[block])?;
1317 assert_eq!(out, "a\nB\nc\n");
1318 assert!(!out.contains('\r'));
1319 Ok(())
1320 }
1321
1322 #[test]
1323 fn apply_blocks_reports_indentation_tolerance() -> Result<()> {
1324 let content = " alpha\n beta\n";
1327 let block = SearchReplaceBlock {
1328 search: vec!["alpha".to_string(), "beta".to_string()],
1329 replace: vec!["alpha".to_string(), "BETA".to_string()],
1330 ..Default::default()
1331 };
1332 let (_out, tolerances) = apply_blocks(content, &[block])?;
1333 assert!(!tolerances.is_empty(), "indentation mismatch should report a tolerance");
1334 Ok(())
1335 }
1336
1337 #[test]
1338 fn apply_blocks_exact_match_reports_no_tolerances() -> Result<()> {
1339 let content = "a\nb\nc\n";
1340 let block = SearchReplaceBlock {
1341 search: vec!["b".to_string()],
1342 replace: vec!["B".to_string()],
1343 ..Default::default()
1344 };
1345 let (_out, tolerances) = apply_blocks(content, &[block])?;
1346 assert!(tolerances.is_empty(), "exact match must not report tolerances");
1347 Ok(())
1348 }
1349
1350 #[test]
1351 fn anchor_parses_start_and_range() -> Result<()> {
1352 let ranged = parse_blocks("<<<<<<< SEARCH @5-8\nfoo\n=======\nbar\n>>>>>>> REPLACE")?;
1353 assert_eq!(ranged[0].anchor_start, Some(5));
1354 assert_eq!(ranged[0].anchor_end, Some(8));
1355 let single = parse_blocks("<<<<<<< SEARCH @3\nfoo\n=======\nbar\n>>>>>>> REPLACE")?;
1356 assert_eq!(single[0].anchor_start, Some(3));
1357 assert_eq!(single[0].anchor_end, None);
1358 let plain = parse_blocks("<<<<<<< SEARCH\nfoo\n=======\nbar\n>>>>>>> REPLACE")?;
1359 assert_eq!(plain[0].anchor_start, None);
1360 Ok(())
1361 }
1362
1363 #[test]
1364 fn anchor_disambiguates_a_repeated_block() -> Result<()> {
1365 let content = "x\nx\nx\n";
1367 let raw = "<<<<<<< SEARCH @3\nx\n=======\nY\n>>>>>>> REPLACE";
1368 let (out, _) = apply_blocks_with_unescape_retry(content, raw)?;
1369 assert_eq!(out, "x\nx\nY\n", "anchor @3 must edit only the 3rd line");
1370 Ok(())
1371 }
1372
1373 #[test]
1374 fn stale_anchor_falls_back_to_normal_search() -> Result<()> {
1375 let content = "a\nx\nb\n";
1377 let raw = "<<<<<<< SEARCH @99\nx\n=======\nY\n>>>>>>> REPLACE";
1378 let (out, _) = apply_blocks_with_unescape_retry(content, raw)?;
1379 assert_eq!(out, "a\nY\nb\n", "stale anchor should fall back, not fail");
1380 Ok(())
1381 }
1382
1383 #[test]
1384 fn change_summary_is_none_for_identical_content() {
1385 assert!(change_summary("a\nb\nc\n", "a\nb\nc\n").is_none());
1386 }
1387
1388 #[test]
1389 fn change_summary_shows_diff_and_counts() {
1390 let summary = change_summary("a\nb\nc\n", "a\nB\nc\n").expect("content changed");
1391 assert!(summary.contains("+1 -1"), "line counts missing: {summary}");
1392 assert!(summary.contains("-b"), "removed line missing: {summary}");
1393 assert!(summary.contains("+B"), "added line missing: {summary}");
1394 }
1395
1396 #[test]
1397 fn operation_result_includes_diff_when_previous_differs() {
1398 let r =
1400 operation_result("edited", "n.txt", Path::new("n.txt"), "a\nB\n", &[], Some("a\nb\n"));
1401 assert!(r.contains("Successfully edited n.txt"));
1402 assert!(r.contains("Changes (+1 -1)"), "diff missing from result: {r}");
1403 }
1404
1405 #[test]
1406 fn operation_result_has_no_diff_for_a_new_file() {
1407 let r = operation_result("wrote", "n.txt", Path::new("n.txt"), "hello\n", &[], None);
1408 assert!(r.contains("Successfully wrote n.txt"));
1409 assert!(!r.contains("Changes"), "new file should carry no diff: {r}");
1410 }
1411
1412 #[test]
1413 fn change_summary_skips_myers_on_oversized_input() {
1414 let big = "x\n".repeat(MAX_DIFF_INPUT_BYTES);
1416 let summary = change_summary("", &big).expect("content changed");
1417 assert!(summary.contains("file too large to diff"), "should skip Myers: {summary}");
1418 }
1419
1420 #[test]
1421 fn change_summary_collapses_a_huge_diff() {
1422 let big: String = (0..MAX_DIFF_LINES + 50).fold(String::new(), |mut s, i| {
1425 let _ = writeln!(s, "line {i}");
1426 s
1427 });
1428 let summary = change_summary("", &big).expect("content changed");
1429 assert!(summary.contains("diff too large to show"), "should collapse: {summary}");
1430 assert!(!summary.contains("line 10"), "must not inline content: {summary}");
1431 }
1432}