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
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
// SPDX-License-Identifier: MIT OR Apache-2.0
// Included by replace_run.inc.rs (responsibility split, v0.1.37 G1).
//
// The consumer side of the run: the per-run tallies and the worker events,
// the loop that writes those events to the data channel, and closing the run
// (read the final counters, decide the verdict, emit the terminal record).
// Split from the scan so the part that reads totals and the part that walks
// the tree can be reasoned about and tested apart.
/// Tallies shared by every walker worker and read once after the join.
///
/// `Ordering::Relaxed`: independent tallies; the final loads run after the
/// parallel join (no data-publication barrier needed).
struct ReplaceCounters {
files_visited: AtomicU64,
files_modified: AtomicU64,
files_skipped: AtomicU64,
total_replacements: AtomicU64,
/// Matches left alone because `--max-replacements` cut the file short.
replacements_suppressed: AtomicU64,
/// G-027: binary rejections, counted apart from generic I/O failures.
binary_rejects: AtomicU64,
first_binary_path: std::sync::Mutex<Option<PathBuf>>,
/// Eligible-file count from the progress precount (0 when progress is off).
files_total: AtomicU64,
/// Set when a walker thread returns `WalkState::Quit`.
///
/// The summary used to read `output::limit_reached()`, which records that
/// the writer dropped a record. That is the writer's budget, not the walk:
/// the same defect found in `search`, where a walk that stopped after 9 of
/// 4000 files still published `false`.
walk_quit: std::sync::atomic::AtomicBool,
}
impl ReplaceCounters {
fn new(files_total: u64) -> Self {
Self {
files_visited: AtomicU64::new(0),
files_modified: AtomicU64::new(0),
files_skipped: AtomicU64::new(0),
total_replacements: AtomicU64::new(0),
replacements_suppressed: AtomicU64::new(0),
binary_rejects: AtomicU64::new(0),
first_binary_path: std::sync::Mutex::new(None),
files_total: AtomicU64::new(files_total),
walk_quit: std::sync::atomic::AtomicBool::new(false),
}
}
}
/// Totals read once, after the parallel join, before the run is concluded.
struct ReplaceTotals {
total_replacements: u64,
/// Matches the runs never touched because the per-file limit was reached.
replacements_suppressed: u64,
files_matched: u64,
files_visited: u64,
files_skipped: u64,
binary_rejects: u64,
/// Eligible-file count from the progress precount, 0 when it did not run.
files_total: u64,
/// Files that could not be read, matched or written, for the summary.
failures: crate::ndjson_types::FailureTally,
/// Roots named in the invocation that do not exist on disk.
missing_roots: Vec<std::path::PathBuf>,
/// Whether a walker thread quit before the tree ended.
walk_quit: bool,
}
fn finish_replace(
totals: ReplaceTotals,
first_binary_path: &std::sync::Mutex<Option<PathBuf>>,
dry_run: bool,
preview: bool,
shutdown: &ShutdownSignal,
writer: &mut NdjsonWriter<impl Write>,
start: Instant,
) -> Result<()> {
let total_repl = totals.total_replacements;
let matched = totals.files_matched;
// B-002g / R-DRY-001: dry-run / preview must not claim disk mutations.
let modified_count =
crate::commands::summary_metrics::files_modified_for_summary(matched, dry_run || preview);
let planned_count =
crate::commands::summary_metrics::files_planned_for_summary(matched, dry_run || preview);
// v0.1.36 A3: a cancelled run emits NO summary. The counters below are
// always partial — the walk stopped before covering its input — and any
// partial summary is read by an agent as a completed conclusion. That was
// the real defect: a run killed by the deadline reported
// `total_replacements: 14035087` with no mention of the cancel, so a
// consumer reading only the last NDJSON line saw an unqualified success.
//
// This matches the guard `search` (search.rs:378) and `delete`
// (delete.rs:313) already apply. The terminal record is the `cancelled`
// envelope instead, whose schema the project already publishes.
//
// Returning `Ok(())` also stops the `NO_MATCHES` path below, which used to
// rewrite the cooperative cancel (124 / 130 / 143) into a misleading exit
// 1 and make the unprocessed remainder look like an empty workspace.
if shutdown.is_shutdown() {
// The terminal record is no longer built here: `run` emits the cancel
// envelope once for every scanning verb as the scan unwinds, so
// returning `Ok(())` lets that single point decide. Building it here as
// well would publish the cancel twice.
return Ok(());
}
let base = Summary {
r#type: "summary",
files_visited: totals.files_visited,
files_matched: matched,
files_modified: modified_count,
files_planned: planned_count,
files_skipped: Some(totals.files_skipped),
total_matches: None,
total_replacements: Some(total_repl),
replacements_suppressed: Some(totals.replacements_suppressed).filter(|n| *n > 0),
binary_rejects: Some(totals.binary_rejects),
files_total: Some(totals.files_total).filter(|n| *n > 0),
files_budget_dropped: Some(crate::output::budget_dropped()).filter(|n| *n > 0),
elapsed_ms: start.elapsed().as_millis() as u64,
walker_stopped_early: totals.walk_quit.then_some(true),
..Summary::default()
};
// A file that could not be read, matched or written is reported by the
// summary (`files_failed`, `errors[]`) instead of staying in a side event.
let (summary, first_failure) = totals.failures.into_summary(base);
// A root named in the invocation that does not exist outranks a rewrite
// that landed: the caller asked about a place the run never reached.
if let Some(first_missing) = totals.missing_roots.first() {
let err = crate::error::AtomwriteError::NotFound {
path: first_missing.clone(),
};
return Err(writer.publish_failure(&summary, err.into()));
}
// A replacement that landed is an answer the run did produce, and the
// summary names every file it could not read or write in `files_failed`
// and `errors[]`. Failing the run would throw that answer away.
//
// The count of planned replacements is not that answer: under
// `--expect-checksum` a drift reports one replacement found and zero
// files modified, and the earlier `total_repl > 0` arm published it as
// `ok: true` with exit 0. Only a write that landed, or a planned one in
// `--dry-run`/`--preview`, may close the run as a success.
if total_repl > 0
&& (modified_count.is_some_and(|n| n > 0) || planned_count.is_some_and(|n| n > 0))
{
writer.write_terminal(&summary)?;
return Ok(());
}
// R7: a run that ends in an error envelope must not publish `ok: true`, and
// the summary is its one terminal event. A file that failed means "no
// matches" would be false, so the first failure wins. G-027: all-binary /
// binary-only skips must not look like NO_MATCHES + retryable.
let binaries = totals.binary_rejects;
let failure = if let Some(err) = first_failure {
err
} else if binaries > 0 && totals.files_matched == 0 {
let path = first_binary_path
.lock()
.ok()
.and_then(|g| g.clone())
.unwrap_or_else(|| PathBuf::from("."));
crate::error::AtomwriteError::BinaryFile { path }.into()
} else {
crate::error::AtomwriteError::NoMatches.into()
};
Err(writer.publish_failure(&summary, failure))
}
enum ReplaceEvent {
Replaced {
path: PathBuf,
replacements: u64,
bytes_before: u64,
bytes_after: u64,
checksum_before: String,
checksum_after: String,
elapsed_ms: u64,
mtime_preserved: bool,
fuzzy: Option<bool>,
strategy: Option<String>,
similarity: Option<f64>,
strategies_tried: Option<u64>,
word_ignored: Option<bool>,
risk_assessment: Option<crate::ndjson_types::WriteRiskAssessment>,
backup_path: Option<String>,
write_strategy: &'static str,
backup_skipped: Option<String>,
},
Progress {
done: u64,
total: u64,
},
DryRun {
path: PathBuf,
replacements: u64,
risk_assessment: Option<crate::ndjson_types::WriteRiskAssessment>,
},
Preview {
path: PathBuf,
replacements: u64,
diff: String,
risk_assessment: Option<crate::ndjson_types::WriteRiskAssessment>,
},
Error {
path: PathBuf,
kind: ReplaceErrorKind,
},
}
enum ReplaceErrorKind {
StateDrift {
expected: String,
actual: String,
},
/// A file the run could not read, match or write; the typed error travels
/// whole so the summary reports its own code, class and exit status.
Failed(anyhow::Error),
/// G-027: binary content is a permanent precondition, not a transient write failure.
BinaryRejected,
JailViolation,
}
/// Write each worker event to the data channel, in arrival order.
///
/// Stops early, leaving the rest undrained, once the shutdown flag is set.
/// Returns the files that failed, for the terminal summary.
fn drain_replace_events(
rx: &crossbeam_channel::Receiver<ReplaceEvent>,
writer: &mut NdjsonWriter<impl Write>,
shutdown: &ShutdownSignal,
start: Instant,
) -> Result<crate::ndjson_types::FailureTally> {
let mut failures = crate::ndjson_types::FailureTally::default();
for event in rx {
if shutdown.is_shutdown() {
break;
}
match event {
ReplaceEvent::Replaced {
path,
replacements,
bytes_before,
bytes_after,
checksum_before,
checksum_after,
elapsed_ms,
mtime_preserved,
fuzzy,
strategy,
similarity,
strategies_tried,
word_ignored,
risk_assessment,
backup_path,
write_strategy,
backup_skipped,
} => {
let path_str = path.display().to_string();
writer.write_event(&ReplaceResult {
r#type: "replaced",
path: path_str,
replacements,
bytes_before,
bytes_after,
checksum_before,
checksum_after,
elapsed_ms,
mtime_preserved: Some(mtime_preserved),
fuzzy,
strategy,
similarity,
strategies_tried,
word_ignored,
risk_assessment,
backup_path,
write_strategy,
backup_skipped,
})?;
}
ReplaceEvent::Progress { done, total } => {
let elapsed = start.elapsed().as_secs_f64();
let rate = if elapsed > 0.0 {
done as f64 / elapsed
} else {
0.0
};
let eta_ms = if rate > 0.0 && total > done {
Some(
((total - done) as f64 / rate * crate::constants::MILLIS_PER_SECOND_F64)
as u64,
)
} else {
None
};
writer.write_event(&ProgressEvent {
r#type: "progress",
done,
total,
rate_per_s: Some(rate),
eta_ms,
phase: "replace".into(),
})?;
}
ReplaceEvent::DryRun {
path,
replacements,
risk_assessment,
} => {
writer.write_event(&DryRunPlan {
r#type: "plan",
operation: "replace".into(),
path: path.display().to_string(),
would_modify: true,
details: Some(format!("{replacements} replacements")),
risk_assessment,
})?;
}
ReplaceEvent::Preview {
path,
replacements,
diff,
risk_assessment,
} => {
writer.write_event(&crate::ndjson_types::ReplacePreview {
r#type: "preview",
path: path.display().to_string(),
replacements,
diff,
risk_assessment,
})?;
}
ReplaceEvent::Error { path, kind } => {
// Binary and jail refusals are skips by design (`files_skipped`);
// the other kinds are failures and join the summary's tally.
let (message, error_class, retryable, failure) = match kind {
ReplaceErrorKind::StateDrift { expected, actual } => (
format!("state drift: expected {expected}, got {actual}"),
crate::error::ErrorClass::Conflict.as_str(),
true,
Some(anyhow::Error::from(
crate::error::AtomwriteError::StateDrift {
path: path.clone(),
expected,
actual,
},
)),
),
ReplaceErrorKind::Failed(err) => {
let (class, retryable) = crate::output::failure_class(&err);
(
crate::output::error_message(&err),
class,
retryable,
Some(err),
)
}
ReplaceErrorKind::BinaryRejected => (
"binary file rejected for text replace (G-015/G-027)".to_string(),
crate::error::ErrorClass::Permanent.as_str(),
false,
None,
),
ReplaceErrorKind::JailViolation => (
"path escapes workspace jail; use --workspace to set a different root"
.to_string(),
crate::error::ErrorClass::Permanent.as_str(),
false,
None,
),
};
writer.write_event(&crate::ndjson_types::ReplaceErrorEvent {
status: "error",
path: path.display().to_string(),
message,
error_class,
retryable,
})?;
if let Some(failure) = failure {
failures.record(&path, failure);
}
}
}
}
Ok(failures)
}