1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
// SPDX-License-Identifier: MIT OR Apache-2.0
// Included by replace_run.inc.rs (file-size split, v0.1.37).
//
// The per-file worker steps: reading and gating one candidate file, and applying
// the replacement to its content. Delivery and the walk live in replace_run.inc.rs.
/// Jail check, bounded read and binary rejection for one file.
///
/// Returns the text content, or `None` after counting the skip and emitting
/// the error event when the file must not be processed.
fn read_candidate(
job: &ReplaceJob,
counters: &ReplaceCounters,
tx: &crossbeam_channel::Sender<ReplaceEvent>,
path: &std::path::Path,
) -> Option<String> {
// Validate path against workspace jail BEFORE processing
if crate::path_safety::validate_path(path, &job.workspace).is_err() {
counters.files_skipped.fetch_add(1, Ordering::Relaxed);
crate::best_effort::send_to_consumer(
tx,
ReplaceEvent::Error {
path: path.to_path_buf(),
kind: ReplaceErrorKind::JailViolation,
},
);
return None;
}
let content = match crate::file_io::read_file_string(path, job.max_size) {
Ok(c) => c,
Err(error) => {
counters.files_skipped.fetch_add(1, Ordering::Relaxed);
// A file that is too large or not text is skipped by design; one
// that could not be accessed is a failure the summary reports.
if !crate::output::is_content_rejection(&error) {
crate::best_effort::send_to_consumer(
tx,
ReplaceEvent::Error {
path: path.to_path_buf(),
kind: ReplaceErrorKind::Failed(error),
},
);
}
return None;
}
};
// G-015 / G-027: refuse binary content with permanent BINARY_FILE taxonomy.
if crate::binary_detect::is_binary(content.as_bytes()) {
counters.files_skipped.fetch_add(1, Ordering::Relaxed);
counters.binary_rejects.fetch_add(1, Ordering::Relaxed);
if let Ok(mut guard) = counters.first_binary_path.lock() {
if guard.is_none() {
*guard = Some(path.to_path_buf());
}
}
crate::best_effort::send_to_consumer(
tx,
ReplaceEvent::Error {
path: path.to_path_buf(),
kind: ReplaceErrorKind::BinaryRejected,
},
);
return None;
}
Some(content)
}
/// Apply the replacement to one file's content: regex, or exact then fuzzy.
fn replace_in_content<'a>(
job: &ReplaceJob,
content: &'a str,
cancel: &mut crate::signal::CancelCheck,
) -> FileReplacement<'a> {
let mut fuzzy_meta: Option<(bool, String, Option<f64>, u64)> = None;
let mut word_ignored = false;
let mut cancelled = false;
let mut failure: Option<anyhow::Error> = None;
// Matches the file still had after `--max-replacements`; the count is a
// floor because a match inside replaced text is not re-scanned.
let mut suppressed = 0u64;
let (replaced, count) = if job.use_regex {
match apply_replacement_cancellable(
&job.pattern,
content,
&job.replacement,
job.max_replacements,
job.preserve_case,
Some(&mut *cancel),
) {
Ok((text, applied, cut)) => {
suppressed = cut;
(text, applied)
}
Err(_) => {
cancelled = true;
(Cow::Borrowed(content), 0)
}
}
} else {
// Fixed string: exact multi first, then fuzzy cascade if zero hits.
let exact_result = apply_replacement_cancellable(
&job.pattern,
content,
&job.replacement,
job.max_replacements,
job.preserve_case,
Some(&mut *cancel),
);
match exact_result {
Err(_) => {
cancelled = true;
(Cow::Borrowed(content), 0)
}
Ok((exact, exact_count, cut)) if exact_count > 0 => {
suppressed = cut;
(exact, exact_count)
}
Ok(_) => {
// v0.1.33 one-shot: never re-scan inserted replacement text.
// Default max applies = 1; pat⊂rep forces 1; hard ceiling applies.
if job.word {
word_ignored = true;
}
if cancel.check().is_err() {
cancelled = true;
(Cow::Borrowed(content), 0)
} else {
match fuzzy::apply_fuzzy_one_pass(
content,
&job.pattern_text,
&job.replacement,
job.fuzzy_opts,
job.max_replacements,
) {
Ok(result) => {
if result.applied > 0 {
if let Some(info) = result.info {
fuzzy_meta = Some((
info.fuzzy,
info.strategy,
info.similarity,
info.strategies_tried,
));
}
(
result.edited.map_or(Cow::Borrowed(content), Cow::Owned),
result.applied,
)
} else {
(Cow::Borrowed(content), 0)
}
}
Err(crate::error::AtomwriteError::Cancelled { .. }) => {
cancelled = true;
(Cow::Borrowed(content), 0)
}
// Not "no match": the cascade could not run on this file
// (for example above the fuzzy content cap).
Err(error) => {
failure = Some(error.into());
(Cow::Borrowed(content), 0)
}
}
}
}
}
};
FileReplacement {
replaced,
count,
suppressed,
fuzzy_meta,
word_ignored,
cancelled,
failure,
}
}