Skip to main content

yarp_cli/reducers/
evidence.rs

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(&sections);
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}