1use std::collections::{BTreeMap, BTreeSet, VecDeque};
2
3use crate::rule_pack::OutputPolicy;
4
5use super::bounded::{LineView, render_line};
6
7const MAX_RECORDS_PER_CLASS: usize = 128;
8const EXAMPLE_BUCKET_INDEX: usize = 4;
9const COMPACT_SUCCESS_SUMMARY_BYTES: usize = 512;
10
11#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
12pub enum EvidenceClass {
13 Outcome,
14 Diagnostic,
15 Structure,
16 Context,
17 Example,
18 Noise,
19}
20
21impl EvidenceClass {
22 const fn index(self) -> Option<usize> {
23 match self {
24 Self::Diagnostic => Some(0),
25 Self::Outcome => Some(1),
26 Self::Structure => Some(2),
27 Self::Context => Some(3),
28 Self::Example => Some(4),
29 Self::Noise => None,
30 }
31 }
32
33 const fn title(self) -> &'static str {
34 match self {
35 Self::Outcome => "outcome",
36 Self::Diagnostic => "diagnostics",
37 Self::Structure => "structure",
38 Self::Context => "diagnostic context",
39 Self::Example => "examples",
40 Self::Noise => "noise",
41 }
42 }
43}
44
45#[derive(Clone, Copy, Debug, Eq, PartialEq)]
46pub struct SourceSpan {
47 pub first_line: u64,
48 pub last_line: u64,
49}
50
51#[derive(Clone, Debug)]
52struct EvidenceRecord {
53 span: SourceSpan,
54 body: Vec<u8>,
55 repeats: u64,
56}
57
58#[derive(Debug)]
59struct Bucket {
60 class: EvidenceClass,
61 head: Vec<EvidenceRecord>,
62 tail: VecDeque<EvidenceRecord>,
63 head_bytes: usize,
64 tail_bytes: usize,
65 head_budget: usize,
66 tail_budget: usize,
67 total: u64,
68 fingerprints: BTreeSet<(u64, u64, usize)>,
69 first_omitted: Option<u64>,
70 last_omitted: Option<u64>,
71}
72
73impl Bucket {
74 fn new(class: EvidenceClass, budget: usize) -> Self {
75 let head_budget = budget / 2;
76 Self {
77 class,
78 head: Vec::new(),
79 tail: VecDeque::new(),
80 head_bytes: 0,
81 tail_bytes: 0,
82 head_budget,
83 tail_budget: budget.saturating_sub(head_budget),
84 total: 0,
85 fingerprints: BTreeSet::new(),
86 first_omitted: None,
87 last_omitted: None,
88 }
89 }
90
91 fn observe_repeat(&mut self, body: &[u8], span: SourceSpan) -> bool {
92 let fingerprint = record_fingerprint(body);
93 if !self.fingerprints.contains(&fingerprint) {
94 return false;
95 }
96 let Some(existing) = self
97 .head
98 .iter_mut()
99 .chain(self.tail.iter_mut())
100 .find(|existing| existing.body == body)
101 else {
102 return false;
103 };
104 self.total = self.total.saturating_add(1);
105 existing.repeats = existing.repeats.saturating_add(1);
106 existing.span.last_line = span.last_line;
107 true
108 }
109
110 fn observe(&mut self, mut record: EvidenceRecord) {
111 self.total = self.total.saturating_add(1);
112 let fingerprint = record_fingerprint(&record.body);
113 if self.fingerprints.contains(&fingerprint) {
114 if let Some(existing) = self
115 .head
116 .iter_mut()
117 .chain(self.tail.iter_mut())
118 .find(|existing| existing.body == record.body)
119 {
120 existing.repeats = existing.repeats.saturating_add(1);
121 existing.span.last_line = record.span.last_line;
122 return;
123 }
124 } else if self.fingerprints.len() < MAX_RECORDS_PER_CLASS {
125 self.fingerprints.insert(fingerprint);
126 }
127 record.repeats = 1;
128 if self.head.len() < MAX_RECORDS_PER_CLASS / 2
129 && self.head_bytes.saturating_add(record.body.len()) <= self.head_budget
130 {
131 self.head_bytes = self.head_bytes.saturating_add(record.body.len());
132 self.head.push(record);
133 return;
134 }
135 if record.body.len() > self.tail_budget {
136 self.note_omitted(record.span);
137 return;
138 }
139 while self.tail.len() >= MAX_RECORDS_PER_CLASS / 2
140 || self.tail_bytes.saturating_add(record.body.len()) > self.tail_budget
141 {
142 let Some(removed) = self.tail.pop_front() else {
143 break;
144 };
145 self.tail_bytes = self.tail_bytes.saturating_sub(removed.body.len());
146 self.note_omitted(removed.span);
147 }
148 if self.tail_bytes.saturating_add(record.body.len()) <= self.tail_budget {
149 self.tail_bytes = self.tail_bytes.saturating_add(record.body.len());
150 self.tail.push_back(record);
151 } else {
152 self.note_omitted(record.span);
153 }
154 }
155
156 fn note_omitted(&mut self, span: SourceSpan) {
157 self.first_omitted = Some(
158 self.first_omitted
159 .map_or(span.first_line, |current| current.min(span.first_line)),
160 );
161 self.last_omitted = Some(
162 self.last_omitted
163 .map_or(span.last_line, |current| current.max(span.last_line)),
164 );
165 }
166
167 fn render_ranked(
168 &self,
169 limit: usize,
170 newline: &[u8],
171 represented: &BTreeSet<u64>,
172 ) -> RenderedBucket {
173 const OMISSION_RESERVE_BYTES: usize = 128;
174
175 if self.total == 0 {
176 return RenderedBucket::default();
177 }
178 let mut records = self.head.iter().chain(&self.tail).collect::<Vec<_>>();
179 records.sort_by_key(|record| record.span.first_line);
180 let represented_count = records
181 .iter()
182 .filter(|record| represented.contains(&record.span.first_line))
183 .count();
184 let candidates = records
185 .into_iter()
186 .filter(|record| !represented.contains(&record.span.first_line))
187 .collect::<Vec<_>>();
188 let mut complete = Vec::new();
189 append_section_heading(&mut complete, self.class, newline);
190 for record in &candidates {
191 render_record(&mut complete, record, newline);
192 }
193 append_omission(
194 &mut complete,
195 self.class,
196 self.total.saturating_sub(
197 u64::try_from(candidates.len().saturating_add(represented_count))
198 .unwrap_or(u64::MAX),
199 ),
200 self.first_omitted,
201 self.last_omitted,
202 newline,
203 );
204 if complete.len() <= limit {
205 return RenderedBucket {
206 body: complete,
207 retained_lines: candidates
208 .iter()
209 .map(|record| record.span.first_line)
210 .collect(),
211 };
212 }
213
214 let heading_bytes = self.class.title().len().saturating_add(7 + newline.len());
215 if limit < heading_bytes.saturating_add(OMISSION_RESERVE_BYTES) {
216 return RenderedBucket::default();
217 }
218 let mut selection_order = Vec::with_capacity(candidates.len());
219 let (mut first, mut last) = (0_usize, candidates.len());
220 while first < last {
221 selection_order.push(first);
222 first += 1;
223 if first < last {
224 last -= 1;
225 selection_order.push(last);
226 }
227 }
228 let mut selected = BTreeSet::new();
229 let mut selected_bytes = heading_bytes;
230 for index in selection_order {
231 let mut rendered = Vec::new();
232 render_record(&mut rendered, candidates[index], newline);
233 if selected_bytes
234 .saturating_add(rendered.len())
235 .saturating_add(OMISSION_RESERVE_BYTES)
236 <= limit
237 {
238 selected_bytes = selected_bytes.saturating_add(rendered.len());
239 selected.insert(index);
240 }
241 }
242
243 let mut body = Vec::with_capacity(limit);
244 append_section_heading(&mut body, self.class, newline);
245 let mut retained_lines = Vec::with_capacity(selected.len());
246 let mut first_omitted = self.first_omitted;
247 let mut last_omitted = self.last_omitted;
248 for (index, record) in candidates.iter().enumerate() {
249 if selected.contains(&index) {
250 render_record(&mut body, record, newline);
251 retained_lines.push(record.span.first_line);
252 } else {
253 merge_omitted_range(&mut first_omitted, &mut last_omitted, record.span);
254 }
255 }
256 let represented_or_retained = represented_count.saturating_add(selected.len());
257 let omitted = self
258 .total
259 .saturating_sub(u64::try_from(represented_or_retained).unwrap_or(u64::MAX));
260 append_omission(
261 &mut body,
262 self.class,
263 omitted,
264 first_omitted,
265 last_omitted,
266 newline,
267 );
268 body.truncate(limit);
269 RenderedBucket {
270 body,
271 retained_lines,
272 }
273 }
274}
275
276#[derive(Debug, Default)]
277struct RenderedBucket {
278 body: Vec<u8>,
279 retained_lines: Vec<u64>,
280}
281
282fn append_section_heading(output: &mut Vec<u8>, class: EvidenceClass, newline: &[u8]) {
283 output.extend_from_slice(format!("== {} ==", class.title()).as_bytes());
284 output.extend_from_slice(newline);
285}
286
287fn append_omission(
288 output: &mut Vec<u8>,
289 class: EvidenceClass,
290 omitted: u64,
291 first_omitted: Option<u64>,
292 last_omitted: Option<u64>,
293 newline: &[u8],
294) {
295 if omitted == 0 {
296 return;
297 }
298 let range = match (first_omitted, last_omitted) {
299 (Some(first), Some(last)) => format!("; source lines {first}:{last}"),
300 _ => String::new(),
301 };
302 output.extend_from_slice(
303 format!("[yarp: omitted {omitted} {} line(s){range}]", class.title()).as_bytes(),
304 );
305 output.extend_from_slice(newline);
306}
307
308fn merge_omitted_range(first: &mut Option<u64>, last: &mut Option<u64>, span: SourceSpan) {
309 *first = Some(first.map_or(span.first_line, |current| current.min(span.first_line)));
310 *last = Some(last.map_or(span.last_line, |current| current.max(span.last_line)));
311}
312
313fn render_record(output: &mut Vec<u8>, record: &EvidenceRecord, newline: &[u8]) {
314 if record.span.first_line == record.span.last_line {
315 output.extend_from_slice(format!("L{}: ", record.span.first_line).as_bytes());
316 } else {
317 output.extend_from_slice(
318 format!("L{}-{}: ", record.span.first_line, record.span.last_line).as_bytes(),
319 );
320 }
321 output.extend_from_slice(strip_line_ending(&record.body));
322 if record.repeats > 1 {
323 output.extend_from_slice(format!(" [repeated {} times]", record.repeats).as_bytes());
324 }
325 output.extend_from_slice(newline);
326}
327
328fn example_key<'a>(family: &str, body: &'a [u8]) -> Option<&'a [u8]> {
329 let body = strip_line_ending(body);
330 let key = match family {
331 "search" | "build" => body.split(|byte| *byte == b':').next()?,
332 "test" => body
333 .windows(3)
334 .position(|window| window == b"...")
335 .map_or(body, |position| &body[..position]),
336 "list" => {
337 let item = body
338 .rsplit(u8::is_ascii_whitespace)
339 .find(|part| !part.is_empty())?;
340 let separator = item.iter().position(|byte| *byte == b'/')?;
341 &item[..separator]
342 }
343 _ => return None,
344 };
345 if key.is_empty() {
346 return None;
347 }
348 Some(&key[..key.len().min(128)])
349}
350
351fn record_fingerprint(body: &[u8]) -> (u64, u64, usize) {
352 let mut first = 0xcbf2_9ce4_8422_2325_u64;
353 let mut second = 0x9e37_79b9_7f4a_7c15_u64;
354 for byte in body {
355 first ^= u64::from(*byte);
356 first = first.wrapping_mul(0x0000_0100_0000_01b3);
357 second ^= u64::from(*byte).wrapping_add(0x9e37_79b9);
358 second = second.rotate_left(7).wrapping_mul(0xbf58_476d_1ce4_e5b9);
359 }
360 (first, second, body.len())
361}
362
363#[derive(Clone, Copy, Debug)]
364pub struct RecoveryMarker<'a> {
365 pub archive_ref: &'a str,
366 pub source: &'a str,
367 pub completeness: &'a str,
368}
369
370#[derive(Debug)]
371pub struct EvidenceCollector {
372 family: &'static str,
373 policy: OutputPolicy,
374 buckets: Vec<Bucket>,
375 total_lines: u64,
376 noise_lines: u64,
377 newline: Vec<u8>,
378 newline_observed: bool,
379 truncated_lines: u64,
380 priority_terms: [Option<EvidenceRecord>; 5],
381 example_keys: BTreeMap<(u64, u64, usize), Vec<u8>>,
382}
383
384impl EvidenceCollector {
385 #[must_use]
386 pub fn new(family: &'static str, policy: OutputPolicy) -> Self {
387 let classes = [
388 EvidenceClass::Diagnostic,
389 EvidenceClass::Outcome,
390 EvidenceClass::Structure,
391 EvidenceClass::Context,
392 EvidenceClass::Example,
393 ];
394 let buckets = classes
395 .into_iter()
396 .map(|class| Bucket::new(class, policy.max_output_bytes))
397 .collect();
398 Self {
399 family,
400 policy,
401 buckets,
402 total_lines: 0,
403 noise_lines: 0,
404 newline: b"\n".to_vec(),
405 newline_observed: false,
406 truncated_lines: 0,
407 priority_terms: [const { None }; 5],
408 example_keys: BTreeMap::new(),
409 }
410 }
411
412 pub fn observe(&mut self, line_number: u64, line: &LineView, class: EvidenceClass) {
413 self.total_lines = self.total_lines.saturating_add(1);
414 if !self.newline_observed && !line.line_ending.is_empty() {
415 self.newline.clone_from(&line.line_ending);
416 self.newline_observed = true;
417 }
418 let source_term_in_bounds = super::classify::has_registered_diagnostic(&line.prefix)
419 || (line.truncated && super::classify::has_registered_diagnostic(&line.tail_bytes()));
420 if class == EvidenceClass::Noise {
421 self.noise_lines = self.noise_lines.saturating_add(1);
422 if !source_term_in_bounds {
423 return;
424 }
425 }
426 let (body, truncated) = render_line(
427 line,
428 self.policy.max_line_bytes,
429 matches!(
430 class,
431 EvidenceClass::Diagnostic | EvidenceClass::Example | EvidenceClass::Noise
432 ),
433 );
434 self.truncated_lines = self.truncated_lines.saturating_add(u64::from(truncated));
435 let span = SourceSpan {
436 first_line: line_number,
437 last_line: line_number,
438 };
439 if class == EvidenceClass::Example && !source_term_in_bounds {
440 if self
441 .buckets
442 .get_mut(EXAMPLE_BUCKET_INDEX)
443 .is_some_and(|bucket| bucket.observe_repeat(&body, span))
444 {
445 return;
446 }
447 if let Some(key) = example_key(self.family, &body) {
448 let fingerprint = record_fingerprint(key);
449 match self.example_keys.get(&fingerprint) {
450 Some(existing) if existing.as_slice() == key => {
451 self.noise_lines = self.noise_lines.saturating_add(1);
452 return;
453 }
454 None if self.example_keys.len() < MAX_RECORDS_PER_CLASS => {
455 self.example_keys.insert(fingerprint, key.to_vec());
456 }
457 Some(_) | None => {}
458 }
459 }
460 }
461 let record = EvidenceRecord {
462 span,
463 body,
464 repeats: 1,
465 };
466 if source_term_in_bounds {
467 for (slot, term) in self
468 .priority_terms
469 .iter_mut()
470 .zip(super::classify::REGISTERED_DIAGNOSTICS)
471 {
472 if slot.is_none() && super::classify::contains_ascii_insensitive(&record.body, term)
473 {
474 *slot = Some(diagnostic_representative(&record, term));
475 }
476 }
477 }
478 let Some(index) = class.index() else {
479 return;
480 };
481 self.buckets[index].observe(record);
482 }
483
484 #[must_use]
485 pub fn render(
486 self,
487 recovery: Option<RecoveryMarker<'_>>,
488 registered_diagnostics: [bool; 5],
489 success: bool,
490 ) -> Vec<u8> {
491 let has_typed_diagnostics = self.buckets.first().is_some_and(|bucket| bucket.total > 0);
492 let mut priority_terms = self
493 .priority_terms
494 .iter()
495 .flatten()
496 .cloned()
497 .collect::<Vec<_>>();
498 priority_terms.sort_by_key(|record| record.span.first_line);
499 priority_terms.dedup_by_key(|record| record.span.first_line);
500 let represented = priority_terms
501 .iter()
502 .map(|record| record.span.first_line)
503 .collect::<BTreeSet<_>>();
504 let source_term_samples = render_source_term_samples(&priority_terms, &self.newline);
505 let mandatory_bytes = source_term_samples.len();
506 let registered = registered_diagnostics
507 .into_iter()
508 .zip(["failure", "panic", "error", "warning", "test result"])
509 .filter_map(|(present, term)| present.then_some(term))
510 .collect::<Vec<_>>();
511 let diagnostic_suffix = if registered.is_empty() {
512 String::new()
513 } else {
514 format!("; source_terms={}", registered.join(","))
515 };
516 let provisional_prefix = render_summary_prefix(
517 self.family,
518 self.total_lines,
519 &diagnostic_suffix,
520 self.total_lines,
521 self.truncated_lines,
522 recovery,
523 &self.newline,
524 );
525 let compact_limit = self
526 .policy
527 .max_output_bytes
528 .min(COMPACT_SUCCESS_SUMMARY_BYTES);
529 let compact = success
530 && !has_typed_diagnostics
531 && registered.is_empty()
532 && priority_terms.is_empty()
533 && provisional_prefix.len().saturating_add(mandatory_bytes) <= compact_limit;
534 let output_limit = if compact {
535 compact_limit
536 } else {
537 self.policy.max_output_bytes
538 };
539 let structural_markers = render_structural_markers(
540 self.noise_lines,
541 self.truncated_lines,
542 &self.newline,
543 recovery.is_none(),
544 );
545 let available = output_limit
546 .saturating_sub(provisional_prefix.len())
547 .saturating_sub(mandatory_bytes);
548 let (sections, omitted) = self.render_ranked_sections(
549 available,
550 &represented,
551 &source_term_samples,
552 &structural_markers,
553 );
554 let prefix = render_summary_prefix(
555 self.family,
556 self.total_lines,
557 &diagnostic_suffix,
558 omitted,
559 self.truncated_lines,
560 recovery,
561 &self.newline,
562 );
563 let mut output = Vec::with_capacity(output_limit.min(64 * 1024));
564 output.extend_from_slice(&prefix);
565 output.extend_from_slice(§ions);
566 if output.len() > output_limit {
567 output.truncate(output_limit);
568 }
569 output
570 }
571
572 fn render_ranked_sections(
573 &self,
574 mut available: usize,
575 represented: &BTreeSet<u64>,
576 source_term_samples: &[u8],
577 structural_markers: &[u8],
578 ) -> (Vec<u8>, u64) {
579 let mut sections = Vec::with_capacity(available.min(64 * 1024));
580 let mut retained_lines = represented.iter().copied().collect::<Vec<_>>();
581 for class in [
582 EvidenceClass::Outcome,
583 EvidenceClass::Diagnostic,
584 EvidenceClass::Context,
585 ] {
586 append_ranked_class(
587 &self.buckets,
588 class,
589 &mut sections,
590 &mut retained_lines,
591 &mut available,
592 &self.newline,
593 represented,
594 );
595 }
596 sections.extend_from_slice(source_term_samples);
597 append_ranked_class(
598 &self.buckets,
599 EvidenceClass::Structure,
600 &mut sections,
601 &mut retained_lines,
602 &mut available,
603 &self.newline,
604 represented,
605 );
606 if structural_markers.len() <= available {
607 available = available.saturating_sub(structural_markers.len());
608 sections.extend_from_slice(structural_markers);
609 }
610 append_ranked_class(
611 &self.buckets,
612 EvidenceClass::Example,
613 &mut sections,
614 &mut retained_lines,
615 &mut available,
616 &self.newline,
617 represented,
618 );
619 retained_lines.sort_unstable();
620 retained_lines.dedup();
621 let omitted = self
622 .total_lines
623 .saturating_sub(u64::try_from(retained_lines.len()).unwrap_or(u64::MAX));
624 (sections, omitted)
625 }
626}
627
628fn append_ranked_class(
629 buckets: &[Bucket],
630 class: EvidenceClass,
631 output: &mut Vec<u8>,
632 retained_lines: &mut Vec<u64>,
633 available: &mut usize,
634 newline: &[u8],
635 represented: &BTreeSet<u64>,
636) {
637 let Some(bucket) = buckets.iter().find(|bucket| bucket.class == class) else {
638 return;
639 };
640 let rendered = bucket.render_ranked(*available, newline, represented);
641 *available = available.saturating_sub(rendered.body.len());
642 output.extend_from_slice(&rendered.body);
643 retained_lines.extend(rendered.retained_lines);
644}
645
646fn render_source_term_samples(priority_terms: &[EvidenceRecord], newline: &[u8]) -> Vec<u8> {
647 let mut output = Vec::new();
648 if !priority_terms.is_empty() {
649 output.extend_from_slice(b"== source term samples ==");
650 output.extend_from_slice(newline);
651 for record in priority_terms {
652 render_record(&mut output, record, newline);
653 }
654 }
655 output
656}
657
658fn render_structural_markers(
659 noise_lines: u64,
660 truncated_lines: u64,
661 newline: &[u8],
662 include: bool,
663) -> Vec<u8> {
664 let mut output = Vec::new();
665 if !include {
666 return output;
667 }
668 if noise_lines > 0 {
669 output
670 .extend_from_slice(format!("[yarp: omitted {noise_lines} routine line(s)]").as_bytes());
671 output.extend_from_slice(newline);
672 }
673 if truncated_lines > 0 {
674 output.extend_from_slice(
675 format!("[yarp: truncated {truncated_lines} retained line(s)]").as_bytes(),
676 );
677 output.extend_from_slice(newline);
678 }
679 output
680}
681
682fn render_summary_prefix(
683 family: &str,
684 total_lines: u64,
685 diagnostic_suffix: &str,
686 omitted: u64,
687 truncated: u64,
688 recovery: Option<RecoveryMarker<'_>>,
689 newline: &[u8],
690) -> Vec<u8> {
691 let mut output = Vec::new();
692 output.extend_from_slice(
693 format!("[yarp summary: {family}; source_lines={total_lines}{diagnostic_suffix}]")
694 .as_bytes(),
695 );
696 output.extend_from_slice(newline);
697 if omitted > 0 || truncated > 0 || recovery.is_some() {
698 append_recovery_marker(&mut output, newline, omitted, truncated, recovery);
699 }
700 output
701}
702
703fn diagnostic_representative(record: &EvidenceRecord, term: &[u8]) -> EvidenceRecord {
704 const MAX_REPRESENTATIVE_BYTES: usize = 32;
705 let body = strip_line_ending(&record.body);
706 if body.len() <= MAX_REPRESENTATIVE_BYTES {
707 return record.clone();
708 }
709 let position = body
710 .windows(term.len())
711 .position(|window| {
712 window
713 .iter()
714 .zip(term)
715 .all(|(left, right)| left.eq_ignore_ascii_case(right))
716 })
717 .unwrap_or(0);
718 let prefix = position.saturating_sub(24);
719 let end = prefix
720 .saturating_add(MAX_REPRESENTATIVE_BYTES)
721 .min(body.len());
722 let mut excerpt = Vec::with_capacity(MAX_REPRESENTATIVE_BYTES + 12);
723 if prefix > 0 {
724 excerpt.extend_from_slice(b"[...] ");
725 }
726 excerpt.extend_from_slice(&body[prefix..end]);
727 if end < body.len() {
728 excerpt.extend_from_slice(b" [...]");
729 }
730 EvidenceRecord {
731 span: record.span,
732 body: excerpt,
733 repeats: record.repeats,
734 }
735}
736
737fn append_recovery_marker(
738 output: &mut Vec<u8>,
739 newline: &[u8],
740 omitted: u64,
741 truncated: u64,
742 recovery: Option<RecoveryMarker<'_>>,
743) {
744 let description = if omitted == 0 && truncated == 0 {
745 "exact output archived".to_owned()
746 } else if truncated == 0 {
747 format!("omitted {omitted} line(s)")
748 } else {
749 format!("omitted {omitted} line(s), truncated {truncated} line(s)")
750 };
751 if let Some(recovery) = recovery {
752 output.extend_from_slice(
753 format!(
754 "[yarp: {description}; ref={}; {} {}]",
755 recovery.archive_ref, recovery.source, recovery.completeness
756 )
757 .as_bytes(),
758 );
759 output.extend_from_slice(newline);
760 output.extend_from_slice(
761 format!(
762 "Search omitted output: yarp search {} 'term|alternate'",
763 recovery.archive_ref
764 )
765 .as_bytes(),
766 );
767 output.extend_from_slice(newline);
768 } else {
769 output.extend_from_slice(format!("[yarp: {description}]").as_bytes());
770 output.extend_from_slice(newline);
771 }
772}
773
774fn strip_line_ending(body: &[u8]) -> &[u8] {
775 body.strip_suffix(b"\r\n")
776 .or_else(|| body.strip_suffix(b"\n"))
777 .unwrap_or(body)
778}
779
780#[cfg(test)]
781mod tests {
782 use std::collections::VecDeque;
783
784 use super::*;
785
786 fn line(value: &str) -> LineView {
787 LineView {
788 prefix: value.as_bytes().to_vec(),
789 tail: VecDeque::from(value.as_bytes().to_vec()),
790 line_ending: if value.ends_with("\r\n") {
791 b"\r\n".to_vec()
792 } else if value.ends_with('\n') {
793 b"\n".to_vec()
794 } else {
795 Vec::new()
796 },
797 total_bytes: value.len(),
798 truncated: false,
799 }
800 }
801
802 fn policy() -> OutputPolicy {
803 OutputPolicy {
804 max_line_bytes: 256,
805 max_output_bytes: 4_096,
806 min_savings_bytes: 8,
807 min_savings_basis_points: 1_000,
808 }
809 }
810
811 #[test]
812 fn renders_typed_sections_and_copyable_recovery() {
813 let mut collector = EvidenceCollector::new("test", policy());
814 collector.observe(1, &line("test one ... ok\r\n"), EvidenceClass::Noise);
815 collector.observe(2, &line("error: failed\n"), EvidenceClass::Diagnostic);
816 collector.observe(3, &line("test result: FAILED\n"), EvidenceClass::Outcome);
817 let output = String::from_utf8(collector.render(
818 Some(RecoveryMarker {
819 archive_ref: "yr_0123456789abcdef0123456789abcdef",
820 source: "stderr",
821 completeness: "complete",
822 }),
823 [false; 5],
824 false,
825 ))
826 .expect("UTF-8");
827 assert!(output.contains("== outcome ==\r\n"));
828 assert!(output.contains("L2: error: failed"));
829 assert!(output.contains("yarp search yr_0123456789abcdef0123456789abcdef"));
830 }
831
832 #[test]
833 fn changed_output_keeps_recovery_even_when_no_line_is_omitted() {
834 let mut collector = EvidenceCollector::new("test", policy());
835 collector.observe(1, &line("error: failed\n"), EvidenceClass::Diagnostic);
836 let output = String::from_utf8(collector.render(
837 Some(RecoveryMarker {
838 archive_ref: "yr_0123456789abcdef0123456789abcdef",
839 source: "stderr",
840 completeness: "complete",
841 }),
842 [false; 5],
843 false,
844 ))
845 .expect("UTF-8");
846 assert!(
847 output
848 .contains("[yarp: exact output archived; ref=yr_0123456789abcdef0123456789abcdef")
849 );
850 assert!(
851 output
852 .contains("Search omitted output: yarp search yr_0123456789abcdef0123456789abcdef")
853 );
854 }
855
856 #[test]
857 fn compact_output_keeps_the_complete_recovery_instruction() {
858 let mut compact = policy();
859 compact.max_output_bytes = 704;
860 let mut collector = EvidenceCollector::new("search", compact);
861 for index in 0..1_000 {
862 collector.observe(
863 index + 1,
864 &line(&format!("src/file{}.rs:{index}: match\n", index % 40)),
865 EvidenceClass::Example,
866 );
867 }
868 let output = collector.render(
869 Some(RecoveryMarker {
870 archive_ref: "yr_0123456789abcdef0123456789abcdef",
871 source: "result_text",
872 completeness: "unknown",
873 }),
874 [false; 5],
875 true,
876 );
877 let text = String::from_utf8_lossy(&output);
878 assert!(output.len() <= COMPACT_SUCCESS_SUMMARY_BYTES);
879 assert!(text.contains(
880 "Search omitted output: yarp search yr_0123456789abcdef0123456789abcdef 'term|alternate'"
881 ));
882 assert!(text.contains("L1: src/file0.rs:0: match"));
883 }
884
885 #[test]
886 fn source_term_samples_disable_the_compact_cap_without_tracker_matches() {
887 let mut compact = policy();
888 compact.max_output_bytes = 704;
889 let mut collector = EvidenceCollector::new("test", compact);
890 for (index, value) in [
891 "failure from source text",
892 "panic from source text",
893 "error from source text",
894 "warning from source text",
895 "test result from source text",
896 ]
897 .into_iter()
898 .enumerate()
899 {
900 collector.observe(
901 u64::try_from(index + 1).expect("line"),
902 &line(&format!("prefix {value} with enough surrounding detail\n")),
903 EvidenceClass::Example,
904 );
905 }
906 let output = String::from_utf8_lossy(&collector.render(
907 Some(RecoveryMarker {
908 archive_ref: "yr_0123456789abcdef0123456789abcdef",
909 source: "result_text",
910 completeness: "complete",
911 }),
912 [false; 5],
913 true,
914 ))
915 .to_ascii_lowercase();
916 for term in ["failure", "panic", "error", "warning", "test result"] {
917 assert!(output.contains(term), "missing {term}: {output}");
918 }
919 assert!(output.len() <= 704);
920 }
921
922 #[test]
923 fn keeps_one_source_line_for_every_registered_diagnostic_category() {
924 let mut compact = policy();
925 compact.max_output_bytes = 704;
926 let mut collector = EvidenceCollector::new("test", compact);
927 for index in 0..100 {
928 collector.observe(
929 index + 1,
930 &line(&format!("error: repeated {index}\n")),
931 EvidenceClass::Diagnostic,
932 );
933 }
934 for (offset, value) in [
935 "warning: late\n",
936 "panic: late\n",
937 "failure: late\n",
938 "test result: FAILED\n",
939 ]
940 .into_iter()
941 .enumerate()
942 {
943 collector.observe(
944 u64::try_from(101 + offset).expect("line"),
945 &line(value),
946 EvidenceClass::Diagnostic,
947 );
948 }
949 let output = String::from_utf8_lossy(&collector.render(
950 Some(RecoveryMarker {
951 archive_ref: "yr_0123456789abcdef0123456789abcdef",
952 source: "result_text",
953 completeness: "complete",
954 }),
955 [true; 5],
956 true,
957 ))
958 .to_ascii_lowercase();
959 for term in ["error", "warning", "panic", "failure", "test result"] {
960 assert!(output.contains(term), "missing {term}: {output}");
961 }
962 assert!(output.contains("yarp search yr_0123456789abcdef0123456789abcdef"));
963 assert!(output.len() <= 704);
964 }
965
966 #[test]
967 fn collapses_exact_repetition_with_a_bounded_counter() {
968 let mut collector = EvidenceCollector::new("list", policy());
969 for index in 0..1_000 {
970 collector.observe(index + 1, &line("same record\n"), EvidenceClass::Example);
971 }
972 let output = String::from_utf8(collector.render(None, [false; 5], true)).expect("UTF-8");
973 assert!(output.contains("repeated 1000 times"));
974 assert_eq!(output.matches("same record").count(), 1);
975 }
976
977 #[test]
978 fn ranks_outcomes_before_diagnostics_and_examples() {
979 let mut collector = EvidenceCollector::new("test", policy());
980 collector.observe(1, &line("example row\n"), EvidenceClass::Example);
981 collector.observe(2, &line("error: failed\n"), EvidenceClass::Diagnostic);
982 collector.observe(3, &line("test result: FAILED\n"), EvidenceClass::Outcome);
983 let output = String::from_utf8(collector.render(None, [false; 5], false)).expect("UTF-8");
984 assert!(
985 output.find("== outcome ==").expect("outcome")
986 < output.find("== diagnostics ==").expect("diagnostics")
987 );
988 assert!(
989 output.find("== diagnostics ==").expect("diagnostics")
990 < output
991 .find("== source term samples ==")
992 .expect("source term samples")
993 );
994 assert!(
995 output
996 .find("== source term samples ==")
997 .expect("source term samples")
998 < output.find("== examples ==").expect("examples")
999 );
1000 }
1001
1002 #[test]
1003 fn prefers_different_search_files() {
1004 let mut collector = EvidenceCollector::new("search", policy());
1005 for index in 0..100 {
1006 collector.observe(
1007 index + 1,
1008 &line(&format!("src/one.rs:{index}: match {index}\n")),
1009 EvidenceClass::Example,
1010 );
1011 }
1012 collector.observe(
1013 101,
1014 &line("src/two.rs:1: another match\n"),
1015 EvidenceClass::Example,
1016 );
1017 let output = String::from_utf8(collector.render(None, [false; 5], true)).expect("UTF-8");
1018 assert!(output.contains("src/one.rs"));
1019 assert!(output.contains("src/two.rs"));
1020 }
1021
1022 #[test]
1023 fn prefers_different_list_groups() {
1024 let mut collector = EvidenceCollector::new("list", policy());
1025 collector.observe(1, &line("group/one\n"), EvidenceClass::Example);
1026 collector.observe(2, &line("group/two\n"), EvidenceClass::Example);
1027 collector.observe(3, &line("other/three\n"), EvidenceClass::Example);
1028 let output = String::from_utf8(collector.render(None, [false; 5], true)).expect("UTF-8");
1029 assert!(output.contains("group/one"));
1030 assert!(!output.contains("group/two"));
1031 assert!(output.contains("other/three"));
1032 }
1033
1034 #[test]
1035 fn collector_memory_is_bounded_by_record_and_byte_budgets() {
1036 let mut collector = EvidenceCollector::new("list", policy());
1037 for index in 0..10_000 {
1038 collector.observe(
1039 index + 1,
1040 &line(&format!("record {index}\n")),
1041 EvidenceClass::Example,
1042 );
1043 }
1044 let output = collector.render(None, [false; 5], true);
1045 assert!(output.len() <= policy().max_output_bytes);
1046 assert!(String::from_utf8_lossy(&output).contains("omitted"));
1047 }
1048}