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