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
/// Write each worker event to stdout in arrival order.
///
/// Returns early, without draining, once the shutdown flag is set. Files that
/// failed are not events of their own: they are tallied and reported by the
/// terminal summary.
fn drain_transform_events(
rx: &crossbeam_channel::Receiver<TransformEvent>,
writer: &mut NdjsonWriter<impl Write>,
shutdown: &ShutdownSignal,
) -> Result<FailureTally> {
let mut failures = FailureTally::default();
for event in rx {
if shutdown.is_shutdown() {
break;
}
match event {
TransformEvent::Transformed {
path,
language,
matches,
bytes_before,
bytes_after,
checksum_before,
checksum_after,
elapsed_ms,
risk_assessment,
write_strategy,
backup_skipped,
} => {
writer.write_event(&TransformResult {
r#type: "transformed",
path: path.display().to_string(),
language,
matches,
replacements: matches,
bytes_before,
bytes_after,
checksum_before,
checksum_after,
elapsed_ms,
risk_assessment,
write_strategy,
backup_skipped,
})?;
}
TransformEvent::Error { path, error } => failures.record(&path, error),
}
}
Ok(failures)
}
enum TransformEvent {
Transformed {
path: PathBuf,
language: String,
matches: u64,
bytes_before: u64,
bytes_after: u64,
checksum_before: String,
checksum_after: String,
elapsed_ms: u64,
risk_assessment: Option<crate::ndjson_types::WriteRiskAssessment>,
write_strategy: &'static str,
backup_skipped: Option<String>,
},
/// A file that could not be read, checked or written; the error travels whole.
Error { path: PathBuf, error: anyhow::Error },
}
/// Multi-rule transform dispatcher (G44).
///
/// Reads rules from `--rules PATH` (YAML file) or `--inline-rules "YAML"`
/// and applies each rule in order. For each rule, the walker is
/// re-constructed with the rule's specific language filter. The summary
/// at the end aggregates counts across all rules.
fn cmd_transform_multi(
args: &TransformArgs,
global: &GlobalArgs,
writer: &mut NdjsonWriter<impl Write>,
_shutdown: &ShutdownSignal,
defaults: &crate::config::DefaultsSection,
write_cfg: &crate::config::WriteSection,
) -> Result<()> {
let start = Instant::now();
let workspace = global.resolve_workspace()?;
let effective_backup = resolve_backup(&args.backup_opts, defaults);
// Load the YAML rules.
let rules: Vec<YamlRule> = if let Some(path) = &args.rules {
load_rules_file(path, global.effective_max_filesize())?
} else if let Some(inline) = &args.inline_rules {
parse_rules(inline, "inline-rules")?
} else {
return Err(crate::error::AtomwriteError::InvalidInput {
reason: "no --rules or --inline-rules provided".into(),
}
.into());
};
if rules.is_empty() {
return Err(crate::error::AtomwriteError::InvalidInput {
reason: "rules manifest is empty (must contain at least one rule)".into(),
}
.into());
}
// For each rule, build a synthetic single-rule TransformArgs and run the
// single-rule pipeline (walker, pattern match, atomic write) through
// `transform_one_rule`, which does NOT write a terminal record: five rules
// must end with one summary of the whole invocation, not five summaries
// that each describe a part and claim to describe everything.
let mut total_visited: u64 = 0;
let mut total_matched: u64 = 0;
let mut total_replacements: u64 = 0;
let mut total_bytes: u64 = 0;
let mut total_skipped: u64 = 0;
let mut total_modified: u64 = 0;
let mut first_failure: Option<anyhow::Error> = None;
// Per-file failures of every rule, counted once for the whole invocation
// so the terminal summary says the run was partial instead of dropping
// what each rule already knew. Each rule walks the tree on its own, so the
// same unreadable file fails once per rule; `failed_paths` keeps
// `files_failed` counting distinct files and `errors[]` listing each once.
let mut failed_paths: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
// Failures a rule counted without publishing a path, which happens past the
// `summary.errors_max_entries` cap. They are added to the distinct count.
let mut unlisted_failed: u64 = 0;
let mut failed_items: Vec<crate::ndjson_types::FileFailure> = Vec::new();
// A root named in the invocation that does not exist is counted once, and
// the rules walk only the roots that exist. The roots are resolved first so
// the error names the same absolute path single-rule mode names.
let resolved_paths =
crate::commands::path_resolution::resolve_paths_against_workspace(&args.paths, &workspace)?;
let (present_paths, missing_roots) =
crate::commands::path_resolution::split_missing_roots(resolved_paths);
for rule in &rules {
let synthetic_args = TransformArgs {
strict: args.strict,
pattern: Some(rule.pattern.clone()),
rewrite: Some(rule.rewrite.clone()),
language: Some(rule.language.clone()),
paths: present_paths.clone(),
include: args.include.clone(),
exclude: args.exclude.clone(),
dry_run: args.dry_run,
rules: None,
inline_rules: None,
backup_opts: crate::cli_args::BackupOpts {
backup: effective_backup.backup.then_some(true),
no_backup: !effective_backup.backup,
keep_backup: effective_backup.keep,
retention: Some(effective_backup.retention),
},
verify_parse: args.verify_parse,
};
// Emit a "rule_begin" event so consumers can correlate subsequent
// transform events with the rule that produced them.
writer.write_event(&crate::ndjson_types::TransformRuleBegin {
r#type: "rule_begin",
id: rule.id.clone(),
language: rule.language.clone(),
})?;
match transform_one_rule(&synthetic_args, global, writer, _shutdown, defaults, write_cfg) {
Ok(run) => {
total_visited = total_visited.saturating_add(run.summary.base.files_visited);
total_matched = total_matched.saturating_add(run.summary.base.files_matched);
total_skipped = total_skipped.saturating_add(run.summary.base.files_skipped.unwrap_or(0));
total_modified = total_modified.saturating_add(run.summary.base.files_modified.unwrap_or(0));
total_replacements =
total_replacements.saturating_add(run.summary.base.total_replacements.unwrap_or(0));
total_bytes =
total_bytes.saturating_add(run.summary.base.total_bytes.unwrap_or(0));
let rule_failed = run.summary.files_failed.unwrap_or(0);
let rule_items = run.summary.errors.unwrap_or_default();
let rule_listed = rule_items.len() as u64;
for item in rule_items {
if failed_paths.insert(item.path.clone()) {
let room = crate::config::summary_errors_max_entries()
.saturating_sub(failed_items.len());
if room > 0 {
failed_items.push(item);
}
}
}
unlisted_failed =
unlisted_failed.saturating_add(rule_failed.saturating_sub(rule_listed));
if let Some(err) = run.first_failure {
writer.write_event(&crate::ndjson_types::TransformRuleError {
r#type: "rule_error",
id: rule.id.clone(),
error: crate::output::error_message(&err),
})?;
if first_failure.is_none() {
first_failure = Some(err);
}
}
}
Err(e) => {
writer.write_event(&crate::ndjson_types::TransformRuleError {
r#type: "rule_error",
id: rule.id.clone(),
error: crate::output::error_message(&e),
})?;
// Continue with the next rule — partial success is OK in
// multi-rule mode; the user can re-run individual rules.
if first_failure.is_none() {
first_failure = Some(e);
}
}
}
}
// A root the invocation named and never reached is counted once, exactly
// as single-rule mode counts it, so both modes report the same field.
let mut root_tally = FailureTally::default();
root_tally.record_missing_roots(&missing_roots);
let (roots_failed, roots_items, _) = root_tally.into_parts();
let total_failed = (failed_paths.len() as u64)
.saturating_add(unlisted_failed)
.saturating_add(roots_failed);
let room = crate::config::summary_errors_max_entries().saturating_sub(failed_items.len());
failed_items.extend(roots_items.into_iter().take(room));
let files_failed = (total_failed > 0).then_some(total_failed);
let summary = crate::ndjson_types::FailureSummary {
base: Summary {
r#type: "summary",
files_visited: total_visited,
files_matched: total_matched,
files_modified: Some(total_modified),
files_skipped: Some(total_skipped),
total_matches: Some(total_replacements),
total_replacements: Some(total_replacements),
total_bytes: Some(total_bytes),
elapsed_ms: start.elapsed().as_millis() as u64,
walker_stopped_early: crate::output::limit_reached().then_some(true),
..Summary::default()
},
files_failed,
partial: crate::ndjson_types::partial_of(files_failed),
errors: (!failed_items.is_empty()).then_some(failed_items),
};
// A root named in the invocation that does not exist outranks a rewrite
// that landed, so the run fails even when it matched.
if let Some(first_missing) = missing_roots.first() {
let err = crate::error::AtomwriteError::NotFound {
path: first_missing.clone(),
};
return Err(writer.publish_failure(&summary, err.into()));
}
// A rewrite that landed is an answer the run did produce, and the summary
// names every file it could not read, check or write in `files_failed` and
// `errors[]`. Failing the run would throw that answer away, which is the
// rule single-rule mode already follows.
if total_replacements > 0 {
writer.write_terminal(&summary)?;
return Ok(());
}
// With nothing rewritten the run has no answer: the first failure explains
// why, and only a run that reached every file falls through to the summary.
if let Some(err) = first_failure {
return Err(writer.publish_failure(&summary, err));
}
writer.write_terminal(&summary)?;
Ok(())
}
/// Read a rules manifest from disk and parse it.
///
/// # Errors
///
/// Returns [`crate::error::AtomwriteError::Io`] when the file cannot be read, and
/// [`crate::error::AtomwriteError::InvalidInput`] when its content is not a
/// valid manifest.
pub fn load_rules_file(
path: &std::path::Path,
max_size: u64,
) -> Result<Vec<YamlRule>> {
let content = crate::file_io::read_file_string(path, max_size)
.with_context(|| format!("cannot read rules file {}", path.display()))?;
parse_rules(&content, &path.display().to_string())
}
/// Parse a rules manifest and report the field that is wrong, not just "invalid YAML".
///
/// A manifest whose YAML is well formed but whose schema is not is a different
/// mistake from a file with a broken quote, and the two need different answers:
/// `SerdeMissingField` and `SerdeUnknownField` are schema errors, so the message
/// names the schema and the field; anything else is a syntax error and the
/// message says so. Both keep the `INVALID_INPUT` exit code.
///
/// # Errors
///
/// Returns [`crate::error::AtomwriteError::InvalidInput`] naming either the
/// schema or the syntax as the failing layer.
pub fn parse_rules(text: &str, label: &str) -> Result<Vec<YamlRule>> {
serde_saphyr::from_str(text).map_err(|error| {
// A rules manifest can fail in two layers, and the agent needs to know
// which one. `serde_saphyr` reports a schema violation either as its own
// typed variant or, when the error crosses the parser boundary, as an
// `ExternalMessage` whose text is the serde diagnostic verbatim — so the
// message prefix is the only signal left on that path. Both are measured.
let message = error.to_string();
let schema = matches!(
error,
serde_saphyr::Error::SerdeMissingField { .. }
| serde_saphyr::Error::SerdeUnknownField { .. }
| serde_saphyr::Error::SerdeInvalidType { .. }
| serde_saphyr::Error::SerdeInvalidValue { .. }
) || message.contains("missing field `")
|| message.contains("unknown field `")
|| message.contains("invalid type:")
|| message.contains("invalid value:");
let layer = if schema {
"rules manifest schema"
} else {
"rules manifest YAML syntax"
};
// Same short-line extraction `AtomwriteError::invalid` uses, without
// the `what:` prefix it would leave empty here.
let detail = message
.lines()
.rev()
.map(str::trim)
.find(|line| line.chars().any(char::is_alphabetic))
.map_or(message.trim(), |line| line.trim_start_matches("error: "));
crate::error::AtomwriteError::InvalidInput {
reason: format!("{label}: invalid {layer}: {detail}"),
}
.into()
})
}
/// The `id` of every rule in a manifest, in file order.
///
/// The ids come from the parsed manifest, so a flow-style sequence, a quoted
/// value or `id :` with a space before the colon all read the same. A rule
/// without `id` is named after its 1-based position, so the label is never
/// empty and two different rules never share one.
///
/// # Errors
///
/// Returns whatever [`load_rules_file`] returns.
pub fn rule_ids_from_file(path: &std::path::Path, max_size: u64) -> Result<Vec<String>> {
let rules = load_rules_file(path, max_size)?;
Ok(rules
.iter()
.enumerate()
.map(|(index, rule)| {
rule.id
.clone()
.unwrap_or_else(|| format!("rule-{}", index + 1))
})
.collect())
}