1mod bounded;
2mod build;
3mod classify;
4mod diff;
5mod evidence;
6pub(crate) mod filter;
7mod list;
8mod log;
9mod search;
10mod status;
11mod test;
12
13use bounded::{LineAccumulator, LineView, ShortRaw};
14pub use evidence::RecoveryMarker;
15use evidence::{EvidenceClass, EvidenceCollector};
16use filter::{AnsiStripper, line_filter_keeps};
17
18use crate::rewrite::TransformDiagnostics;
19use crate::rule_pack::{self as yarp_rule_pack, Action, OutputPolicy, Reducer, Rule};
20
21const DIAGNOSTIC_CONTEXT_LINES: usize = 6;
22
23#[derive(Debug, Default)]
24struct RegisteredDiagnosticTracker {
25 matched: [bool; 5],
26 window: u128,
27}
28
29impl RegisteredDiagnosticTracker {
30 fn push(&mut self, byte: u8) {
31 let lower = byte.to_ascii_lowercase();
32 self.window = (self.window << 8) | u128::from(lower);
33 match lower {
34 b'e' if tail_matches(self.window, FAILURE) => self.matched[0] = true,
35 b'c' if tail_matches(self.window, PANIC) => self.matched[1] = true,
36 b'r' if tail_matches(self.window, ERROR) => self.matched[2] = true,
37 b'g' if tail_matches(self.window, WARNING) => self.matched[3] = true,
38 b't' if tail_matches(self.window, TEST_RESULT) => self.matched[4] = true,
39 _ => {}
40 }
41 }
42
43 fn observe_slice(&mut self, body: &[u8]) {
44 for (matched, term) in self
45 .matched
46 .iter_mut()
47 .zip(classify::REGISTERED_DIAGNOSTICS)
48 {
49 if !*matched && classify::contains_ascii_insensitive(body, term) {
50 *matched = true;
51 }
52 }
53 }
54
55 fn end_line(&mut self) {
56 self.window = 0;
57 }
58}
59
60const FAILURE: (u128, usize) = (0x66_61_69_6c_75_72_65, 56);
61const PANIC: (u128, usize) = (0x70_61_6e_69_63, 40);
62const ERROR: (u128, usize) = (0x65_72_72_6f_72, 40);
63const WARNING: (u128, usize) = (0x77_61_72_6e_69_6e_67, 56);
64const TEST_RESULT: (u128, usize) = (0x74_65_73_74_20_72_65_73_75_6c_74, 88);
65
66fn tail_matches(window: u128, term: (u128, usize)) -> bool {
67 let mask = (1_u128 << term.1) - 1;
68 window & mask == term.0
69}
70
71#[derive(Debug)]
72pub struct StreamReducer {
73 kind: Reducer,
74 success_policy: OutputPolicy,
75 failure_policy: OutputPolicy,
76 success: EvidenceCollector,
77 failure: EvidenceCollector,
78 raw: ShortRaw,
79 line: LineAccumulator,
80 ansi: Option<AnsiStripper>,
81 line_number: u64,
82 diagnostic_context: usize,
83 registered_diagnostics: RegisteredDiagnosticTracker,
84 transform_diagnostics: TransformDiagnostics,
85}
86
87impl StreamReducer {
88 pub fn new(rule: &Rule) -> Result<Self, String> {
94 Self::new_with_transform_diagnostics(rule, TransformDiagnostics::default())
95 }
96
97 pub(crate) fn new_with_transform_diagnostics(
98 rule: &Rule,
99 transform_diagnostics: TransformDiagnostics,
100 ) -> Result<Self, String> {
101 if rule.action != Action::Reduce {
102 return Err("cannot create a reducer for a passthrough rule".to_owned());
103 }
104 let memory_bound = yarp_rule_pack::stream_memory_bound(rule)?;
105 if memory_bound > yarp_rule_pack::MAX_STREAM_MEMORY_BYTES {
106 return Err(format!(
107 "rule requires {memory_bound} bytes per stream, above the {}-byte limit",
108 yarp_rule_pack::MAX_STREAM_MEMORY_BYTES
109 ));
110 }
111 let kind = rule
112 .reducer
113 .clone()
114 .ok_or_else(|| "reduction rule is missing a reducer".to_owned())?;
115 let success_policy = rule
116 .success
117 .ok_or_else(|| "reduction rule is missing a success policy".to_owned())?;
118 let failure_policy = rule
119 .failure
120 .ok_or_else(|| "reduction rule is missing a failure policy".to_owned())?;
121 let max_line = success_policy
122 .max_line_bytes
123 .max(failure_policy.max_line_bytes);
124 let strip_ansi = !matches!(
125 kind,
126 Reducer::LineFilter {
127 strip_ansi: false,
128 ..
129 }
130 );
131 let family = family_name(&kind);
132 Ok(Self {
133 kind,
134 success_policy,
135 failure_policy,
136 success: EvidenceCollector::new(family, success_policy),
137 failure: EvidenceCollector::new(family, failure_policy),
138 raw: ShortRaw::new(success_policy, failure_policy),
139 line: LineAccumulator::new(max_line, max_line),
140 ansi: strip_ansi.then(AnsiStripper::new),
141 line_number: 0,
142 diagnostic_context: 0,
143 registered_diagnostics: RegisteredDiagnosticTracker::default(),
144 transform_diagnostics,
145 })
146 }
147
148 pub fn push(&mut self, chunk: &[u8]) {
149 self.raw.push(chunk);
150 for byte in chunk {
151 if self.line.observe_source(*byte) {
152 self.registered_diagnostics.push(*byte);
153 }
154 let output = self
155 .ansi
156 .as_mut()
157 .map_or(Some(*byte), |stripper| stripper.push_byte(*byte));
158 if let Some(output) = output {
159 self.line.push_output(output);
160 }
161 if *byte == b'\n' {
162 let line = self.line.take();
163 self.process_line(&line);
164 }
165 }
166 }
167
168 #[must_use]
169 pub fn finish(mut self, success: bool, recovery: Option<RecoveryMarker<'_>>) -> Vec<u8> {
170 if !self.line.is_empty() {
171 let line = self.line.take();
172 self.process_line(&line);
173 }
174 let (collector, policy) = if success {
175 (self.success, self.success_policy)
176 } else {
177 (self.failure, self.failure_policy)
178 };
179 self.raw.choose(
180 collector.render(recovery, self.registered_diagnostics.matched, success),
181 policy,
182 )
183 }
184
185 fn process_line(&mut self, line: &LineView) {
186 self.line_number = self.line_number.saturating_add(1);
187 if classify::has_registered_diagnostic(&line.prefix) {
188 self.registered_diagnostics.observe_slice(&line.prefix);
189 }
190 if line.truncated {
191 let tail = line.tail_bytes();
192 if classify::has_registered_diagnostic(&tail) {
193 self.registered_diagnostics.observe_slice(&tail);
194 }
195 }
196 let mut class = if classify::is_tool_outcome(&line.prefix) {
197 EvidenceClass::Outcome
198 } else if self.transform_diagnostics.matches_line(&line.prefix) {
199 EvidenceClass::Diagnostic
200 } else {
201 classify_line(&self.kind, line)
202 };
203 if class == EvidenceClass::Diagnostic {
204 self.diagnostic_context = DIAGNOSTIC_CONTEXT_LINES;
205 } else if self.diagnostic_context > 0 {
206 if class == EvidenceClass::Noise || class == EvidenceClass::Example {
207 class = EvidenceClass::Context;
208 }
209 self.diagnostic_context -= 1;
210 }
211 self.registered_diagnostics.end_line();
212 self.success.observe(self.line_number, line, class);
213 self.failure.observe(self.line_number, line, class);
214 }
215}
216
217fn family_name(kind: &Reducer) -> &'static str {
218 match kind {
219 Reducer::SearchSummary => "search",
220 Reducer::DiffSummary => "diff",
221 Reducer::TestSummary => "test",
222 Reducer::BuildSummary => "build",
223 Reducer::LogSummary => "log",
224 Reducer::StatusSummary => "status",
225 Reducer::ListSummary => "list",
226 Reducer::LineFilter { .. } => "line filter",
227 }
228}
229
230fn classify_line(kind: &Reducer, line: &LineView) -> EvidenceClass {
231 match kind {
232 Reducer::SearchSummary => search::classify(&line.prefix),
233 Reducer::DiffSummary => diff::classify(&line.prefix),
234 Reducer::TestSummary => test::classify(&line.prefix),
235 Reducer::BuildSummary => build::classify(&line.prefix),
236 Reducer::LogSummary => log::classify(&line.prefix),
237 Reducer::StatusSummary => status::classify(&line.prefix),
238 Reducer::ListSummary => list::classify(&line.prefix),
239 Reducer::LineFilter { drop, keep, .. } => {
240 if line_filter_keeps(line, drop, keep) {
241 EvidenceClass::Example
242 } else {
243 EvidenceClass::Noise
244 }
245 }
246 }
247}
248
249pub fn reduce_bytes(rule: &Rule, input: &[u8], success: bool) -> Result<Vec<u8>, String> {
255 reduce_bytes_with_recovery(rule, input, success, None)
256}
257
258pub fn reduce_bytes_with_recovery(
264 rule: &Rule,
265 input: &[u8],
266 success: bool,
267 recovery: Option<RecoveryMarker<'_>>,
268) -> Result<Vec<u8>, String> {
269 reduce_bytes_with_recovery_and_transform_diagnostics(
270 rule,
271 input,
272 success,
273 recovery,
274 TransformDiagnostics::default(),
275 )
276}
277
278pub fn reduce_bytes_with_recovery_and_transform_diagnostics(
284 rule: &Rule,
285 input: &[u8],
286 success: bool,
287 recovery: Option<RecoveryMarker<'_>>,
288 transform_diagnostics: TransformDiagnostics,
289) -> Result<Vec<u8>, String> {
290 let mut reducer = StreamReducer::new_with_transform_diagnostics(rule, transform_diagnostics)?;
291 for chunk in input.chunks(8 * 1024) {
292 reducer.push(chunk);
293 }
294 Ok(reducer.finish(success, recovery))
295}
296
297pub fn configured_memory_bound(rule: &Rule) -> Result<usize, String> {
303 yarp_rule_pack::stream_memory_bound(rule)
304}
305
306#[cfg(test)]
307mod tests {
308 use std::fmt::Write as _;
309
310 use crate::rule_pack::{CommandMatcher, OutputPolicy};
311
312 use super::*;
313
314 fn policy(max: usize, savings: usize) -> OutputPolicy {
315 OutputPolicy {
316 max_line_bytes: 256,
317 max_output_bytes: max,
318 min_savings_bytes: savings,
319 min_savings_basis_points: 1_000,
320 }
321 }
322
323 fn rule(reducer: Reducer) -> Rule {
324 Rule {
325 id: "test/rule".to_owned(),
326 matcher: CommandMatcher {
327 program: vec!["test".to_owned()],
328 argv_prefix: Vec::new(),
329 argv_contains_all: Vec::new(),
330 },
331 action: Action::Reduce,
332 transform: None,
333 reducer: Some(reducer),
334 success: Some(policy(1_024, 8)),
335 failure: Some(policy(2_048, 8)),
336 }
337 }
338
339 #[test]
340 fn uses_real_exit_status_policy() {
341 let input = numbered_lines("line", 200);
342 let success = reduce_bytes(&rule(Reducer::ListSummary), input.as_bytes(), true)
343 .expect("success reduction");
344 let failure = reduce_bytes(&rule(Reducer::ListSummary), input.as_bytes(), false)
345 .expect("failure reduction");
346 assert!(
347 failure.len() > success.len(),
348 "success={} failure={}",
349 success.len(),
350 failure.len()
351 );
352 }
353
354 #[test]
355 fn keeps_tool_session_identifiers_needed_for_follow_up() {
356 let input = format!(
357 "Chunk ID: abc\nProcess running with session ID 12345\n{}",
358 numbered_lines("path", 2_000)
359 );
360 let output =
361 reduce_bytes(&rule(Reducer::ListSummary), input.as_bytes(), true).expect("reduction");
362 let text = String::from_utf8(output).expect("UTF-8");
363 assert!(text.contains("Process running with session ID 12345"));
364 }
365
366 #[test]
367 fn rejects_rules_above_the_stream_memory_limit() {
368 let mut oversized = rule(Reducer::ListSummary);
369 let policy = OutputPolicy {
370 max_line_bytes: 1_048_576,
371 max_output_bytes: 4_194_304,
372 min_savings_bytes: 1_048_576,
373 min_savings_basis_points: 1_000,
374 };
375 oversized.success = Some(policy);
376 oversized.failure = Some(policy);
377 let error = StreamReducer::new(&oversized).expect_err("memory limit");
378 assert!(error.contains("per stream"));
379 }
380
381 #[test]
382 fn output_is_independent_of_chunk_boundaries() {
383 let input = numbered_lines("path:1:match", 400);
384 let rule = rule(Reducer::SearchSummary);
385 let expected = reduce_bytes(&rule, input.as_bytes(), true).expect("reduction");
386 for chunk_size in 1..32 {
387 let mut reducer = StreamReducer::new(&rule).expect("reducer");
388 for chunk in input.as_bytes().chunks(chunk_size) {
389 reducer.push(chunk);
390 }
391 assert_eq!(
392 reducer.finish(true, None),
393 expected,
394 "chunk size {chunk_size}"
395 );
396 }
397 }
398
399 #[test]
400 fn keeps_diagnostics_from_selected_transform_stages() {
401 let plan = crate::rewrite::select_result_plan("cargo test | tee -p /dev/full")
402 .expect("tee pipeline");
403 let input = format!(
404 "{}tee: /dev/full: No space left on device\n",
405 "test routine ... ok\n".repeat(1_000)
406 );
407 let output = reduce_bytes_with_recovery_and_transform_diagnostics(
408 &plan.rule,
409 input.as_bytes(),
410 false,
411 None,
412 plan.transform_diagnostics,
413 )
414 .expect("reduction");
415 assert!(String::from_utf8_lossy(&output).contains("tee: /dev/full"));
416 }
417
418 #[test]
419 fn test_summary_keeps_failures_and_drops_progress() {
420 let input = format!(
421 "{}error: build failed\ntest result: FAILED\n",
422 "test routine ... ok\n".repeat(1_000)
423 );
424 let output =
425 reduce_bytes(&rule(Reducer::TestSummary), input.as_bytes(), false).expect("reduction");
426 let text = String::from_utf8(output).expect("UTF-8");
427 assert!(!text.contains("test routine"));
428 assert!(text.contains("error: build failed"));
429 assert!(text.contains("test result: FAILED"));
430 }
431
432 #[test]
433 fn keeps_registered_diagnostics_from_the_tail_of_a_bounded_long_line() {
434 let mut compact = rule(Reducer::SearchSummary);
435 compact.success.as_mut().expect("policy").max_line_bytes = 256;
436 compact.failure.as_mut().expect("policy").max_line_bytes = 256;
437 let input = format!("{} warning: tail evidence\n", "x".repeat(1_024));
438 let output = reduce_bytes(&compact, input.as_bytes(), true).expect("reduction");
439 assert!(String::from_utf8_lossy(&output).contains("warning"));
440 }
441
442 #[test]
443 fn records_registered_diagnostics_from_the_middle_of_an_oversized_line() {
444 let mut compact = rule(Reducer::SearchSummary);
445 compact.success.as_mut().expect("policy").max_line_bytes = 256;
446 compact.failure.as_mut().expect("policy").max_line_bytes = 256;
447 let input = format!(
448 "{} warning: middle evidence {}\n",
449 "x".repeat(1_024),
450 "y".repeat(1_024)
451 );
452 let output = reduce_bytes(&compact, input.as_bytes(), true).expect("reduction");
453 assert!(String::from_utf8_lossy(&output).contains("source_terms=warning"));
454 }
455
456 #[test]
457 fn applies_the_line_limit_before_stripping_ansi() {
458 let mut input = b"\x1b]".to_vec();
459 input.extend(std::iter::repeat_n(b'x', 400));
460 input.extend_from_slice(b"\x07visible\n");
461 input.extend_from_slice(numbered_lines("tail", 200).as_bytes());
462 let rule = rule(Reducer::LineFilter {
463 strip_ansi: true,
464 drop: Vec::new(),
465 keep: Vec::new(),
466 });
467 let output = reduce_bytes(&rule, &input, true).expect("reduction");
468 assert!(
469 String::from_utf8_lossy(&output).contains("line truncated"),
470 "raw source length must control line truncation"
471 );
472 }
473
474 #[test]
475 fn every_builtin_reducer_obeys_both_hard_output_budgets() {
476 let input = numbered_lines("diagnostic line", 4_000);
477 let mut registry = crate::rules::Registry::builtins_only();
478 for summary in registry.summaries().expect("built-in summaries") {
479 if summary.rule.action != Action::Reduce {
480 continue;
481 }
482 assert!(
483 configured_memory_bound(&summary.rule).expect("memory bound") < 4 * 1024 * 1024,
484 "{} exceeds the per-stream memory target",
485 summary.rule.id
486 );
487 for success in [true, false] {
488 let output = reduce_bytes(&summary.rule, input.as_bytes(), success)
489 .unwrap_or_else(|error| panic!("{}: {error}", summary.rule.id));
490 let policy = if success {
491 summary.rule.success.expect("success policy")
492 } else {
493 summary.rule.failure.expect("failure policy")
494 };
495 assert!(
496 output.len() <= policy.max_output_bytes,
497 "{} emitted {} bytes with a {} byte budget",
498 summary.rule.id,
499 output.len(),
500 policy.max_output_bytes
501 );
502 }
503 }
504 }
505
506 #[test]
507 fn every_builtin_reducer_keeps_short_output_exact() {
508 let input = b"one useful line\n";
509 let mut registry = crate::rules::Registry::builtins_only();
510 for summary in registry.summaries().expect("built-in summaries") {
511 if summary.rule.action != Action::Reduce {
512 continue;
513 }
514 let output = reduce_bytes(&summary.rule, input, true)
515 .unwrap_or_else(|error| panic!("{}: {error}", summary.rule.id));
516 assert_eq!(output, input, "{}", summary.rule.id);
517 }
518 }
519
520 fn numbered_lines(prefix: &str, count: usize) -> String {
521 let mut output = String::new();
522 for index in 0..count {
523 writeln!(&mut output, "{prefix} {index}: repeated output")
524 .expect("write synthetic output");
525 }
526 output
527 }
528
529 #[test]
530 fn unknown_binary_bytes_are_processed_without_utf8() {
531 let mut input = vec![0xff; 4_096];
532 input.push(b'\n');
533 let output = reduce_bytes(&rule(Reducer::SearchSummary), &input, true).expect("reduction");
534 assert!(output.len() <= 1_024);
535 }
536}