Skip to main content

relux_runtime/vm/
buffer.rs

1use std::collections::HashMap;
2use std::sync::Arc;
3
4use regex::Regex;
5use std::sync::Mutex;
6use tokio::sync::Notify;
7
8use crate::observe::structured::BufferEventKind;
9use crate::observe::structured::EventSeq;
10use crate::observe::structured::StructuredLogBuilder;
11use crate::observe::structured::Utf8Stream;
12use crate::vm::context::FailPattern;
13
14// --- FailPatternHit --------------------------------------
15
16/// A fail pattern matched in the output buffer.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct FailPatternHit {
19    /// The pattern string that was being watched for (regex source or literal).
20    pub(crate) pattern: String,
21    /// Whether `pattern` is a regex (`true`) or a literal substring (`false`).
22    pub(crate) is_regex: bool,
23    /// The actual text in the buffer that matched.
24    pub(crate) matched_text: String,
25}
26
27// --- MatchSlices -----------------------------------------
28
29/// `(before, matched, after)` slices around a match. Used by the VM to push a
30/// `BufferEventKind::Matched` describing how the cursor advanced.
31///
32/// All three strings carry the *full* bytes around the match, untruncated.
33/// The viewer reconstructs each shell's append-only buffer from the `grew`
34/// stream and validates that `before + matched + after` equals the
35/// currently-unmatched buffer tail at the moment of the match.
36pub type MatchSlices = (String, String, String);
37
38// --- Tail truncation helpers (failure-context capture only) ---
39// `match_slices` does NOT use these - match events ship full bytes so the
40// viewer can rebuild append-only history losslessly. These helpers are
41// kept for `snapshot_tail` and other places that intentionally want a
42// human-sized excerpt of the buffer.
43
44fn truncate_before(s: &str, max: usize) -> String {
45    if s.len() <= max {
46        s.to_string()
47    } else {
48        let start = s.ceil_char_boundary(s.len() - max);
49        format!("...{}", &s[start..])
50    }
51}
52
53pub(crate) fn regex_error_summary(e: &regex::Error) -> String {
54    let full = e.to_string();
55    full.lines()
56        .rev()
57        .find(|l| !l.is_empty())
58        .unwrap_or(&full)
59        .strip_prefix("error: ")
60        .unwrap_or(&full)
61        .to_string()
62}
63
64fn match_slices(text: &str, pos: usize, end_pos: usize, matched: &str) -> MatchSlices {
65    (
66        text[..pos].to_string(),
67        matched.to_string(),
68        text[end_pos..].to_string(),
69    )
70}
71
72// --- Match Types -----------------------------------------
73
74/// Marker trait for match payload types.
75pub trait MatchKind {}
76
77/// Payload for a literal match.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct LiteralMatch(pub String);
80impl MatchKind for LiteralMatch {}
81
82/// Payload for a regex match (capture groups by index).
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct RegexMatch(pub HashMap<String, String>);
85impl MatchKind for RegexMatch {}
86
87/// A match result with absolute byte offsets and typed payload.
88#[derive(Debug, Clone)]
89pub struct Match<T: MatchKind> {
90    /// Absolute byte offset of match start (accounts for all prior truncations).
91    pub start: usize,
92    /// Absolute byte offset of match end.
93    pub end: usize,
94    /// Bytes consumed (everything up to and including the match, relative to current buffer).
95    pub consumed: usize,
96    /// The matched content.
97    pub value: T,
98}
99
100// --- Multimatch types ------------------------------------
101
102/// A pattern the multimatch scan is still looking for.
103/// `regex` is `None` for literal patterns; the source string lives in
104/// `pattern_str` either way so the builder can record it without going
105/// back to the IR.
106#[derive(Debug, Clone)]
107pub struct PatternSlot {
108    pub(crate) pattern_str: String,
109    pub(crate) regex: Option<Regex>,
110}
111
112impl PatternSlot {
113    pub fn literal(needle: String) -> Self {
114        Self {
115            pattern_str: needle,
116            regex: None,
117        }
118    }
119
120    pub fn regex(source: String, compiled: Regex) -> Self {
121        Self {
122            pattern_str: source,
123            regex: Some(compiled),
124        }
125    }
126
127    pub fn is_regex(&self) -> bool {
128        self.regex.is_some()
129    }
130
131    pub fn pattern(&self) -> &str {
132        &self.pattern_str
133    }
134}
135
136/// Result of a successful scan against one pattern slot.
137#[derive(Debug, Clone)]
138pub struct MultiMatchHit {
139    /// The full bytes that matched. Equal to `whole.as_str()` for regex,
140    /// equal to the needle for literal.
141    pub matched_text: String,
142    /// Absolute byte offset of match start (accounts for all prior drains).
143    pub start_abs: usize,
144    /// Absolute byte offset of match end.
145    pub end_abs: usize,
146    /// `before` slice (the prefix of `decoded` ahead of the match) captured
147    /// at the moment the scan ran. Used by the caller when emitting the
148    /// per-pattern `Matched` buffer event.
149    pub before: String,
150    /// `after` slice (the suffix of `decoded` after the match).
151    pub after: String,
152}
153
154// --- OutputBuffer ----------------------------------------
155
156struct BufferInner {
157    /// Cleanly-decoded bytes available for matching. Always valid UTF-8.
158    /// Invalid input bytes are surfaced as `U+FFFD` here via `Utf8Stream`,
159    /// so byte offsets and char-aware slicing coincide for drains.
160    decoded: String,
161    /// Absolute byte offset (in decoded coordinates) of the first byte
162    /// currently held in `decoded`. Advanced on every drain; used to
163    /// compute `Match.start` / `Match.end` across the shell's lifetime.
164    base: usize,
165    /// Streaming UTF-8 decoder; holds back the trailing bytes of any
166    /// incomplete multi-byte sequence until the next `append`.
167    utf8: Utf8Stream,
168}
169
170#[derive(Clone)]
171pub struct OutputBuffer {
172    inner: Arc<Mutex<BufferInner>>,
173    pub(crate) notify: Arc<Notify>,
174    /// Log builder used to emit buffer events (grew/matched/reset)
175    /// while still holding the inner mutex. Optional so unit tests
176    /// can construct an `OutputBuffer` without a log surface.
177    log: Option<StructuredLogBuilder>,
178    shell_name: String,
179    shell_marker: String,
180}
181
182impl OutputBuffer {
183    /// Construct an `OutputBuffer` wired to the given log builder.
184    /// `append`/`consume_*`/`clear` emit their corresponding buffer events
185    /// on `log` while still holding the inner mutex, preventing a race
186    /// between byte appends and event order. The inner mutex is a
187    /// `std::sync::Mutex` - every critical section is pure CPU work, so no
188    /// `.await` ever happens under the guard and a blocking lock is correct.
189    pub fn new(log: StructuredLogBuilder, shell_name: String, shell_marker: String) -> Self {
190        Self {
191            inner: Arc::new(Mutex::new(BufferInner {
192                decoded: String::new(),
193                base: 0,
194                utf8: Utf8Stream::new(),
195            })),
196            notify: Arc::new(Notify::new()),
197            log: Some(log),
198            shell_name,
199            shell_marker,
200        }
201    }
202
203    /// Construct an `OutputBuffer` with no log surface - buffer-event
204    /// emissions are silently dropped. Unit-test only.
205    #[cfg(test)]
206    pub fn for_tests() -> Self {
207        Self {
208            inner: Arc::new(Mutex::new(BufferInner {
209                decoded: String::new(),
210                base: 0,
211                utf8: Utf8Stream::new(),
212            })),
213            notify: Arc::new(Notify::new()),
214            log: None,
215            shell_name: String::new(),
216            shell_marker: String::new(),
217        }
218    }
219
220    pub async fn append(&self, bytes: &[u8]) {
221        let mut inner = self.inner.lock().unwrap();
222        let decoded = inner.utf8.feed(bytes);
223        if !decoded.is_empty() {
224            inner.decoded.push_str(&decoded);
225            if let Some(log) = &self.log {
226                log.push_buffer_event(
227                    &self.shell_name,
228                    &self.shell_marker,
229                    BufferEventKind::Grew { data: decoded },
230                );
231            }
232        }
233        drop(inner);
234        self.notify.notify_waiters();
235    }
236
237    /// Find literal, drain the decoded prefix up to the match end, push the
238    /// `Matched` buffer event while still holding the inner lock, and return
239    /// the match plus the `EventSeq` of the just-pushed buffer event. All
240    /// under one lock.
241    pub async fn consume_literal(&self, needle: &str) -> Option<(Match<LiteralMatch>, EventSeq)> {
242        let mut inner = self.inner.lock().unwrap();
243        let pos = inner.decoded.find(needle)?;
244        let end_pos = pos + needle.len();
245
246        let (before, matched_str, after) = match_slices(&inner.decoded, pos, end_pos, needle);
247
248        let consumed = end_pos;
249        let m = Match {
250            start: inner.base + pos,
251            end: inner.base + end_pos,
252            consumed,
253            value: LiteralMatch(needle.to_string()),
254        };
255
256        inner.decoded.drain(..end_pos);
257        inner.base += end_pos;
258
259        let buffer_seq = self.emit_matched(before, matched_str, after);
260        Some((m, buffer_seq))
261    }
262
263    /// Find regex, drain via split_to, push the `Matched` buffer event,
264    /// and return the match plus the `EventSeq` of the just-pushed
265    /// buffer event. All under one lock.
266    ///
267    /// Guards against partial-line matches: if the match ends at the buffer
268    /// boundary and the buffer does not end with a newline, the last line may
269    /// still be arriving. In that case we return `None` so the caller waits
270    /// for more data rather than consuming an incomplete line.
271    pub async fn consume_regex(&self, re: &Regex) -> Option<(Match<RegexMatch>, EventSeq)> {
272        let mut inner = self.inner.lock().unwrap();
273        let (pos, end_pos, matched_str, captures) = {
274            let cap = re.captures(&inner.decoded)?;
275            let whole = cap.get(0)?;
276            let pos = whole.start();
277            let end_pos = whole.end();
278            if is_partial_line_match(re, end_pos, &inner.decoded) {
279                return None;
280            }
281            let matched_str = whole.as_str().to_string();
282            let mut captures = HashMap::new();
283            for i in 0..cap.len() {
284                if let Some(m) = cap.get(i) {
285                    captures.insert(i.to_string(), m.as_str().to_string());
286                }
287            }
288            (pos, end_pos, matched_str, captures)
289        };
290
291        let (before, _, after) = match_slices(&inner.decoded, pos, end_pos, &matched_str);
292
293        let consumed = end_pos;
294        let m = Match {
295            start: inner.base + pos,
296            end: inner.base + end_pos,
297            consumed,
298            value: RegexMatch(captures),
299        };
300
301        inner.decoded.drain(..end_pos);
302        inner.base += end_pos;
303
304        let buffer_seq = self.emit_matched(before, matched_str, after);
305        Some((m, buffer_seq))
306    }
307
308    /// Check fail pattern against buffer, then try to consume literal - under one lock.
309    /// Returns Err if fail pattern found, Ok(Some) if literal consumed, Ok(None) if not found.
310    /// On success the `Matched` buffer event is pushed before releasing the lock.
311    pub async fn fail_check_consume_literal(
312        &self,
313        needle: &str,
314        fail_pattern: Option<&FailPattern>,
315    ) -> Result<Option<(Match<LiteralMatch>, EventSeq)>, FailPatternHit> {
316        let mut inner = self.inner.lock().unwrap();
317
318        // Check fail pattern first
319        if let Some(fp) = fail_pattern
320            && let Some(hit) = check_fail_in_buffer(&inner.decoded, fp)
321        {
322            return Err(hit);
323        }
324
325        // Try to consume the literal
326        let Some(pos) = inner.decoded.find(needle) else {
327            return Ok(None);
328        };
329        let end_pos = pos + needle.len();
330
331        let (before, matched_str, after) = match_slices(&inner.decoded, pos, end_pos, needle);
332
333        let consumed = end_pos;
334        let m = Match {
335            start: inner.base + pos,
336            end: inner.base + end_pos,
337            consumed,
338            value: LiteralMatch(needle.to_string()),
339        };
340
341        inner.decoded.drain(..end_pos);
342        inner.base += end_pos;
343
344        let buffer_seq = self.emit_matched(before, matched_str, after);
345        Ok(Some((m, buffer_seq)))
346    }
347
348    /// Check fail pattern against buffer, then try to consume regex - under one lock.
349    /// Returns Err if fail pattern found, Ok(Some) if regex consumed, Ok(None) if not found.
350    /// On success the `Matched` buffer event is pushed before releasing the lock.
351    pub async fn fail_check_consume_regex(
352        &self,
353        re: &Regex,
354        fail_pattern: Option<&FailPattern>,
355    ) -> Result<Option<(Match<RegexMatch>, EventSeq)>, FailPatternHit> {
356        let mut inner = self.inner.lock().unwrap();
357
358        // Check fail pattern first
359        if let Some(fp) = fail_pattern
360            && let Some(hit) = check_fail_in_buffer(&inner.decoded, fp)
361        {
362            return Err(hit);
363        }
364
365        let (pos, end_pos, matched_str, captures) = {
366            let Some(cap) = re.captures(&inner.decoded) else {
367                return Ok(None);
368            };
369            let Some(whole) = cap.get(0) else {
370                return Ok(None);
371            };
372            let pos = whole.start();
373            let end_pos = whole.end();
374            if is_partial_line_match(re, end_pos, &inner.decoded) {
375                return Ok(None);
376            }
377            let matched_str = whole.as_str().to_string();
378            let mut captures = HashMap::new();
379            for i in 0..cap.len() {
380                if let Some(m) = cap.get(i) {
381                    captures.insert(i.to_string(), m.as_str().to_string());
382                }
383            }
384            (pos, end_pos, matched_str, captures)
385        };
386
387        let (before, _, after) = match_slices(&inner.decoded, pos, end_pos, &matched_str);
388
389        let consumed = end_pos;
390        let m = Match {
391            start: inner.base + pos,
392            end: inner.base + end_pos,
393            consumed,
394            value: RegexMatch(captures),
395        };
396
397        inner.decoded.drain(..end_pos);
398        inner.base += end_pos;
399
400        let buffer_seq = self.emit_matched(before, matched_str, after);
401        Ok(Some((m, buffer_seq)))
402    }
403
404    /// Push a `Matched` buffer event on the log, if one is wired up.
405    /// Returns the event seq (or `0` when no log is configured).
406    fn emit_matched(&self, before: String, matched: String, after: String) -> EventSeq {
407        if let Some(log) = &self.log {
408            log.push_buffer_event(
409                &self.shell_name,
410                &self.shell_marker,
411                BufferEventKind::Matched {
412                    before,
413                    matched,
414                    after,
415                },
416            )
417        } else {
418            0
419        }
420    }
421
422    /// Scan every still-unmatched slot against the current decoded buffer
423    /// **without draining and without checking fail patterns**. Returns,
424    /// per slot index, an `Option<MultiMatchHit>` (`Some` iff the slot
425    /// matched this round). Regex slots respect the partial-line guard.
426    ///
427    /// `block_entry` is the absolute offset of the multimatch block's entry
428    /// point. The scan operates against `decoded[block_entry - base ..]`.
429    pub async fn multimatch_scan(
430        &self,
431        slots: &mut [PatternSlot],
432        block_entry: usize,
433    ) -> Vec<Option<MultiMatchHit>> {
434        let inner = self.inner.lock().unwrap();
435        let start_rel = block_entry.saturating_sub(inner.base);
436        let text = if start_rel >= inner.decoded.len() {
437            ""
438        } else {
439            &inner.decoded[start_rel..]
440        };
441
442        let mut results = Vec::with_capacity(slots.len());
443        for slot in slots.iter() {
444            let hit = match &slot.regex {
445                Some(re) => scan_regex_in(re, text, inner.base + start_rel),
446                None => scan_literal_in(&slot.pattern_str, text, inner.base + start_rel),
447            };
448            results.push(hit);
449        }
450        results
451    }
452
453    /// Drop the prefix of `decoded` up to absolute offset `target` without
454    /// emitting any buffer event. Used at multimatch block exit to advance
455    /// past `max(end_abs)` across all pattern hits in a single step.
456    pub async fn drain_to(&self, target: usize) {
457        let mut inner = self.inner.lock().unwrap();
458        if target <= inner.base {
459            return;
460        }
461        let advance = target - inner.base;
462        debug_assert!(
463            advance <= inner.decoded.len(),
464            "drain_to target {target} exceeds buffer end {base}+{len}={end}",
465            base = inner.base,
466            len = inner.decoded.len(),
467            end = inner.base + inner.decoded.len(),
468        );
469        let advance = advance.min(inner.decoded.len());
470        inner.decoded.drain(..advance);
471        inner.base += advance;
472    }
473
474    /// Current absolute `base` offset - the byte position in this shell's
475    /// lifetime stream where `decoded` starts. Used by the VM at multimatch
476    /// block entry so the loop's scan operates in absolute coordinates that
477    /// remain stable across in-block `Grew` appends.
478    pub async fn base_offset(&self) -> usize {
479        let inner = self.inner.lock().unwrap();
480        inner.base
481    }
482
483    /// Push a `Matched` buffer event for a multimatch per-pattern hit.
484    /// Same `before`/`matched`/`after` shape as single-match, but **does
485    /// not drain** - the actual drain happens once at block exit via
486    /// `drain_to`. Returns the `EventSeq` of the just-emitted event.
487    pub fn push_multimatch_matched_event(
488        &self,
489        before: String,
490        matched: String,
491        after: String,
492    ) -> EventSeq {
493        let _guard = self.inner.lock().unwrap();
494        self.emit_matched(before, matched, after)
495    }
496
497    /// Check fail pattern against current buffer (peek only, no drain).
498    pub async fn check_fail_pattern(
499        &self,
500        fail_pattern: Option<&FailPattern>,
501    ) -> Option<FailPatternHit> {
502        let fp = fail_pattern?;
503        let inner = self.inner.lock().unwrap();
504        check_fail_in_buffer(&inner.decoded, fp)
505    }
506
507    /// Drain the cleanly-decoded portion of the buffer, advancing base.
508    /// Trailing bytes of an incomplete UTF-8 sequence stay carried over inside
509    /// `Utf8Stream`, to be completed by a future `append`. Emits a `Reset`
510    /// buffer event carrying the consumed prefix - byte-identical to the
511    /// concatenation of `Grew` payloads emitted since the previous reset -
512    /// before releasing the lock. Returns the consumed prefix.
513    pub async fn clear(&self) -> String {
514        let mut inner = self.inner.lock().unwrap();
515        let consumed = std::mem::take(&mut inner.decoded);
516        inner.base += consumed.len();
517        if let Some(log) = &self.log {
518            log.push_buffer_event(
519                &self.shell_name,
520                &self.shell_marker,
521                BufferEventKind::Reset {
522                    consumed: consumed.clone(),
523                },
524            );
525        }
526        consumed
527    }
528
529    /// Return the tail of the current buffer (last `n` chars) as a string.
530    pub async fn snapshot_tail(&self, n: usize) -> String {
531        let inner = self.inner.lock().unwrap();
532        truncate_before(&inner.decoded, n)
533    }
534
535    /// Return remaining unmatched buffer data (decoded prefix as bytes).
536    /// Pending bytes of an incomplete UTF-8 sequence held back by
537    /// `Utf8Stream` are not returned.
538    pub async fn remaining(&self) -> Vec<u8> {
539        let inner = self.inner.lock().unwrap();
540        inner.decoded.as_bytes().to_vec()
541    }
542}
543
544/// Returns `true` if a `$`-anchored regex matched at the buffer boundary
545/// where the buffer does not end with a newline - meaning the last line may
546/// still be arriving and `$` matched end-of-string rather than end-of-line.
547///
548/// Only applies when the regex source ends with an *unescaped* `$` anchor.
549/// Patterns ending in `\$` (literal dollar sign) are not anchored. Patterns
550/// without a trailing `$` are never deferred.
551fn is_partial_line_match(re: &Regex, match_end: usize, text: &str) -> bool {
552    has_trailing_anchor(re.as_str()) && match_end == text.len() && !text.ends_with('\n')
553}
554
555/// `true` iff `src` ends in an unescaped `$` anchor. Counts the run of
556/// trailing backslashes before the final `$`: an even count (including zero)
557/// means the `$` is not escaped.
558fn has_trailing_anchor(src: &str) -> bool {
559    let Some(stripped) = src.strip_suffix('$') else {
560        return false;
561    };
562    let trailing_backslashes = stripped.bytes().rev().take_while(|&b| b == b'\\').count();
563    trailing_backslashes % 2 == 0
564}
565
566fn scan_regex_in(re: &Regex, text: &str, base: usize) -> Option<MultiMatchHit> {
567    let cap = re.captures(text)?;
568    let whole = cap.get(0)?;
569    let pos = whole.start();
570    let end_pos = whole.end();
571    if is_partial_line_match(re, end_pos, text) {
572        return None;
573    }
574    let matched = whole.as_str().to_string();
575    Some(MultiMatchHit {
576        matched_text: matched.clone(),
577        start_abs: base + pos,
578        end_abs: base + end_pos,
579        before: text[..pos].to_string(),
580        after: text[end_pos..].to_string(),
581    })
582}
583
584fn scan_literal_in(needle: &str, text: &str, base: usize) -> Option<MultiMatchHit> {
585    let pos = text.find(needle)?;
586    let end_pos = pos + needle.len();
587    Some(MultiMatchHit {
588        matched_text: needle.to_string(),
589        start_abs: base + pos,
590        end_abs: base + end_pos,
591        before: text[..pos].to_string(),
592        after: text[end_pos..].to_string(),
593    })
594}
595
596/// Check if a fail pattern matches in the given text. Returns (pattern_str, matched_text).
597fn check_fail_in_buffer(text: &str, pattern: &FailPattern) -> Option<FailPatternHit> {
598    match pattern {
599        FailPattern::Regex(re) => {
600            let m = re.find(text)?;
601            Some(FailPatternHit {
602                pattern: re.as_str().to_string(),
603                is_regex: true,
604                matched_text: m.as_str().to_string(),
605            })
606        }
607        FailPattern::Literal(s) => {
608            text.find(s.as_str())?;
609            Some(FailPatternHit {
610                pattern: s.clone(),
611                is_regex: false,
612                matched_text: s.clone(),
613            })
614        }
615    }
616}
617
618// --- Tests -----------------------------------------------
619
620#[cfg(test)]
621mod tests {
622    use std::path::PathBuf;
623    use std::time::Instant;
624
625    use super::*;
626    use crate::observe::progress;
627    use regex::RegexBuilder;
628
629    /// Construct an `OutputBuffer` wired to a fresh `StructuredLogBuilder`,
630    /// returning both so tests can assert on the buffer events that the
631    /// `OutputBuffer` emits.
632    fn wired_buffer() -> (
633        OutputBuffer,
634        StructuredLogBuilder,
635        tokio::sync::mpsc::UnboundedReceiver<crate::observe::progress::ProgressEvent>,
636    ) {
637        let (tx, rx) = progress::channel();
638        let sources = relux_core::table::SharedTable::new();
639        let builder = StructuredLogBuilder::new(
640            tx,
641            Instant::now(),
642            sources,
643            Arc::from(PathBuf::from("/project").as_path()),
644        );
645        let buf = OutputBuffer::new(builder.clone(), "sh".into(), "m".into());
646        (buf, builder, rx)
647    }
648
649    /// Inspect the last buffer event the builder accumulated.
650    fn last_matched(builder: &StructuredLogBuilder) -> Option<(String, String, String)> {
651        let events = builder.buffer_events_for_tests();
652        events.last().and_then(|ev| match &ev.kind {
653            BufferEventKind::Matched {
654                before,
655                matched,
656                after,
657            } => Some((before.clone(), matched.clone(), after.clone())),
658            _ => None,
659        })
660    }
661
662    /// Inspect the last `Reset` buffer event the builder accumulated.
663    fn last_reset(builder: &StructuredLogBuilder) -> Option<String> {
664        let events = builder.buffer_events_for_tests();
665        events.last().and_then(|ev| match &ev.kind {
666            BufferEventKind::Reset { consumed } => Some(consumed.clone()),
667            _ => None,
668        })
669    }
670
671    /// Collect every `Grew` payload the builder has accumulated, in order.
672    fn all_grew(builder: &StructuredLogBuilder) -> Vec<String> {
673        builder
674            .buffer_events_for_tests()
675            .iter()
676            .filter_map(|ev| match &ev.kind {
677                BufferEventKind::Grew { data } => Some(data.clone()),
678                _ => None,
679            })
680            .collect()
681    }
682
683    // --- truncate_before ---------------------------------
684
685    #[test]
686    fn truncate_before_short_string_unchanged() {
687        assert_eq!(truncate_before("hello", 10), "hello");
688    }
689
690    #[test]
691    fn truncate_before_exact_length_unchanged() {
692        assert_eq!(truncate_before("hello", 5), "hello");
693    }
694
695    #[test]
696    fn truncate_before_keeps_last_n_chars() {
697        assert_eq!(truncate_before("hello world", 5), "...world");
698    }
699
700    #[test]
701    fn truncate_before_empty_string() {
702        assert_eq!(truncate_before("", 5), "");
703    }
704
705    #[test]
706    fn truncate_before_max_zero() {
707        assert_eq!(truncate_before("hello", 0), "...");
708    }
709
710    // --- OutputBuffer::append / remaining ----------------
711
712    #[tokio::test]
713    async fn output_buffer_append_and_remaining() {
714        let buf = OutputBuffer::for_tests();
715        buf.append(b"hello").await;
716        assert_eq!(buf.remaining().await, b"hello");
717    }
718
719    #[tokio::test]
720    async fn output_buffer_append_empty_bytes() {
721        let buf = OutputBuffer::for_tests();
722        buf.append(b"").await;
723        assert!(buf.remaining().await.is_empty());
724    }
725
726    // --- OutputBuffer::consume_literal -------------------
727
728    #[tokio::test]
729    async fn consume_literal_basic() {
730        let (buf, builder, _rx) = wired_buffer();
731        buf.append(b"hello world").await;
732        let (m, _buffer_seq) = buf.consume_literal("hello").await.unwrap();
733        assert_eq!(m.start, 0);
734        assert_eq!(m.end, 5);
735        assert_eq!(m.consumed, 5);
736        assert_eq!(m.value.0, "hello");
737        let (before, matched, after) = last_matched(&builder).expect("matched event");
738        assert_eq!(before, "");
739        assert_eq!(matched, "hello");
740        assert_eq!(after, " world");
741        // Buffer should have " world" remaining
742        assert_eq!(buf.remaining().await, b" world");
743    }
744
745    #[tokio::test]
746    async fn consume_literal_drains_up_to_match_end() {
747        let buf = OutputBuffer::for_tests();
748        buf.append(b"prefix MATCH suffix").await;
749        let (m, _) = buf.consume_literal("MATCH").await.unwrap();
750        assert_eq!(m.start, 7);
751        assert_eq!(m.end, 12);
752        assert_eq!(m.consumed, 12);
753        assert_eq!(buf.remaining().await, b" suffix");
754    }
755
756    #[tokio::test]
757    async fn consume_literal_not_found() {
758        let buf = OutputBuffer::for_tests();
759        buf.append(b"hello world").await;
760        assert!(buf.consume_literal("xyz").await.is_none());
761        assert_eq!(buf.remaining().await, b"hello world");
762    }
763
764    #[tokio::test]
765    async fn consume_literal_absolute_offsets_after_drain() {
766        let buf = OutputBuffer::for_tests();
767        buf.append(b"aaa bbb ccc").await;
768        let (m1, _) = buf.consume_literal("aaa").await.unwrap();
769        assert_eq!(m1.start, 0);
770        assert_eq!(m1.end, 3);
771        let (m2, _) = buf.consume_literal("bbb").await.unwrap();
772        assert_eq!(m2.start, 4);
773        assert_eq!(m2.end, 7);
774        assert_eq!(buf.remaining().await, b" ccc");
775    }
776
777    #[tokio::test]
778    async fn consume_literal_context_carries_full_before_and_after() {
779        let (buf, builder, _rx) = wired_buffer();
780        let huge_prefix = "x".repeat(500);
781        let huge_suffix = "y".repeat(500);
782        buf.append(format!("{huge_prefix}MATCH{huge_suffix}").as_bytes())
783            .await;
784        let _ = buf.consume_literal("MATCH").await.unwrap();
785        let (before, matched, after) = last_matched(&builder).expect("matched event");
786        assert_eq!(before, huge_prefix);
787        assert_eq!(matched, "MATCH");
788        assert_eq!(after, huge_suffix);
789    }
790
791    #[tokio::test]
792    async fn consume_literal_handles_invalid_utf8_in_buffer() {
793        // Regression test: invalid bytes (here 0xFF) must not corrupt offsets
794        // for the drain after the match - `Utf8Stream` surfaces them as a
795        // U+FFFD replacement and matching works in decoded coordinates.
796        let buf = OutputBuffer::for_tests();
797        let mut bytes = b"prefix".to_vec();
798        bytes.push(0xFF);
799        bytes.extend_from_slice(b"MATCH suffix");
800        buf.append(&bytes).await;
801        let (m, _) = buf.consume_literal("MATCH").await.expect("found");
802        assert_eq!(m.value.0, "MATCH");
803        assert_eq!(buf.remaining().await, " suffix".as_bytes());
804    }
805
806    // --- OutputBuffer::consume_regex ---------------------
807
808    #[tokio::test]
809    async fn consume_regex_basic() {
810        let buf = OutputBuffer::for_tests();
811        buf.append(b"abc 123 def").await;
812        let re = Regex::new(r"\d+").unwrap();
813        let (m, _) = buf.consume_regex(&re).await.unwrap();
814        assert_eq!(m.start, 4);
815        assert_eq!(m.end, 7);
816        assert_eq!(m.value.0.get("0").unwrap(), "123");
817        assert_eq!(buf.remaining().await, b" def");
818    }
819
820    #[tokio::test]
821    async fn consume_regex_with_captures() {
822        let buf = OutputBuffer::for_tests();
823        buf.append(b"name: Alice age: 30\n").await;
824        let re = Regex::new(r"name: (\w+) age: (\d+)").unwrap();
825        let (m, _) = buf.consume_regex(&re).await.unwrap();
826        assert_eq!(m.start, 0);
827        assert_eq!(m.end, 19);
828        assert_eq!(m.value.0.get("0").unwrap(), "name: Alice age: 30");
829        assert_eq!(m.value.0.get("1").unwrap(), "Alice");
830        assert_eq!(m.value.0.get("2").unwrap(), "30");
831    }
832
833    #[tokio::test]
834    async fn consume_regex_not_found() {
835        let buf = OutputBuffer::for_tests();
836        buf.append(b"hello world").await;
837        let re = Regex::new(r"\d+").unwrap();
838        assert!(buf.consume_regex(&re).await.is_none());
839        assert_eq!(buf.remaining().await, b"hello world");
840    }
841
842    #[tokio::test]
843    async fn consume_regex_absolute_offsets_after_drain() {
844        let buf = OutputBuffer::for_tests();
845        buf.append(b"aaa 123 bbb 456\n").await;
846        let re = Regex::new(r"\d+").unwrap();
847        let (m1, _) = buf.consume_regex(&re).await.unwrap();
848        assert_eq!(m1.start, 4);
849        assert_eq!(m1.end, 7);
850        let (m2, _) = buf.consume_regex(&re).await.unwrap();
851        assert_eq!(m2.start, 12);
852        assert_eq!(m2.end, 15);
853    }
854
855    // --- Partial-line guard ------------------------------
856
857    #[tokio::test]
858    async fn consume_regex_defers_partial_line() {
859        let buf = OutputBuffer::for_tests();
860        buf.append(b"hello wor").await;
861        let re = RegexBuilder::new(r"^(.+)$")
862            .multi_line(true)
863            .build()
864            .unwrap();
865        assert!(buf.consume_regex(&re).await.is_none());
866        assert_eq!(buf.remaining().await, b"hello wor");
867
868        buf.append(b"ld\n").await;
869        let (m, _) = buf.consume_regex(&re).await.unwrap();
870        assert_eq!(m.value.0.get("0").unwrap(), "hello world");
871    }
872
873    #[tokio::test]
874    async fn consume_regex_allows_match_before_partial_tail() {
875        let buf = OutputBuffer::for_tests();
876        buf.append(b"first line\nsecond li").await;
877        let re = RegexBuilder::new(r"^(.+)$")
878            .multi_line(true)
879            .build()
880            .unwrap();
881        let (m, _) = buf.consume_regex(&re).await.unwrap();
882        assert_eq!(m.value.0.get("1").unwrap(), "first line");
883    }
884
885    #[tokio::test]
886    async fn fail_check_consume_regex_defers_partial_line() {
887        let buf = OutputBuffer::for_tests();
888        buf.append(b"partial data").await;
889        let re = RegexBuilder::new(r"^(.+)$")
890            .multi_line(true)
891            .build()
892            .unwrap();
893        let result = buf.fail_check_consume_regex(&re, None).await;
894        assert!(result.unwrap().is_none());
895
896        buf.append(b"\n").await;
897        let result = buf.fail_check_consume_regex(&re, None).await;
898        let (m, _) = result.unwrap().unwrap();
899        assert_eq!(m.value.0.get("0").unwrap(), "partial data");
900    }
901
902    #[tokio::test]
903    async fn consume_regex_handles_invalid_utf8_in_buffer() {
904        let buf = OutputBuffer::for_tests();
905        let mut bytes = b"abc".to_vec();
906        bytes.push(0xFF);
907        bytes.extend_from_slice(b" 123 def");
908        buf.append(&bytes).await;
909        let re = Regex::new(r"\d+").unwrap();
910        let (m, _) = buf.consume_regex(&re).await.expect("found");
911        assert_eq!(m.value.0.get("0").unwrap(), "123");
912        assert_eq!(buf.remaining().await, " def".as_bytes());
913    }
914
915    #[tokio::test]
916    async fn consume_regex_does_not_defer_on_escaped_trailing_dollar() {
917        let buf = OutputBuffer::for_tests();
918        buf.append(b"price: $9").await;
919        // Pattern source ends with `$` literally, but it is escaped (`\$`),
920        // so it is NOT an anchor. Must not be treated as a partial-line match.
921        let re = Regex::new(r"price: \$\d+").unwrap();
922        let (m, _) = buf
923            .consume_regex(&re)
924            .await
925            .expect("escaped trailing dollar must not defer");
926        assert_eq!(m.value.0.get("0").unwrap(), "price: $9");
927    }
928
929    // --- has_trailing_anchor -----------------------------
930
931    #[test]
932    fn has_trailing_anchor_unescaped() {
933        assert!(super::has_trailing_anchor("foo$"));
934        assert!(super::has_trailing_anchor(r"^(.+)$"));
935        // Two backslashes = an escaped backslash followed by an anchor.
936        assert!(super::has_trailing_anchor(r"foo\\$"));
937    }
938
939    #[test]
940    fn has_trailing_anchor_escaped() {
941        assert!(!super::has_trailing_anchor(r"price: \$"));
942        // Three backslashes = escaped backslash + escaped dollar.
943        assert!(!super::has_trailing_anchor(r"foo\\\$"));
944    }
945
946    #[test]
947    fn has_trailing_anchor_no_dollar() {
948        assert!(!super::has_trailing_anchor("foo"));
949        assert!(!super::has_trailing_anchor(""));
950    }
951
952    // --- OutputBuffer::clear -----------------------------
953
954    #[tokio::test]
955    async fn clear_empties_buffer_and_returns_consumed() {
956        let buf = OutputBuffer::for_tests();
957        buf.append(b"hello world").await;
958        let consumed = buf.clear().await;
959        assert_eq!(consumed, "hello world");
960        assert!(buf.remaining().await.is_empty());
961    }
962
963    #[tokio::test]
964    async fn clear_advances_base_correctly() {
965        let buf = OutputBuffer::for_tests();
966        buf.append(b"hello world").await;
967        let _ = buf.clear().await;
968        buf.append(b"abc 123\n").await;
969        let re = Regex::new(r"\d+").unwrap();
970        let (m, _) = buf.consume_regex(&re).await.unwrap();
971        // base should be 11 (from clear) + 4 (from "abc ") = absolute offset 15
972        assert_eq!(m.start, 15);
973        assert_eq!(m.end, 18);
974    }
975
976    #[tokio::test]
977    async fn clear_drops_incomplete_utf8_trailing_sequence() {
978        let (buf, builder, _rx) = wired_buffer();
979        // U+1F389 PARTY POPPER, encoded as F0 9F 8E 89. Feed "ok" then only
980        // the first two bytes of the codepoint - Utf8Stream holds them back.
981        buf.append(b"ok").await;
982        buf.append(&[0xF0, 0x9F]).await;
983        let _ = buf.clear().await;
984        let consumed = last_reset(&builder).expect("reset event");
985        // Only the decoded prefix is emitted; the partial bytes are silently
986        // held back (verified separately in clear_preserves_partial_utf8_in_buffer).
987        assert_eq!(consumed, "ok");
988    }
989
990    #[tokio::test]
991    async fn clear_consumed_equals_sum_of_grew_payloads() {
992        let (buf, builder, _rx) = wired_buffer();
993        buf.append(b"alpha ").await;
994        buf.append(b"beta ").await;
995        buf.append("gamma\n".as_bytes()).await;
996        let grew_sum: String = all_grew(&builder).concat();
997        let _ = buf.clear().await;
998        let consumed = last_reset(&builder).expect("reset event");
999        assert_eq!(consumed, grew_sum);
1000        assert_eq!(consumed, "alpha beta gamma\n");
1001    }
1002
1003    #[tokio::test]
1004    async fn clear_preserves_partial_utf8_in_buffer() {
1005        let (buf, builder, _rx) = wired_buffer();
1006        // First two bytes of U+1F389 only - entire buffer is `pending`.
1007        buf.append(&[0xF0, 0x9F]).await;
1008        let _ = buf.clear().await;
1009        let consumed = last_reset(&builder).expect("reset event");
1010        assert_eq!(consumed, "");
1011        // Now finish the codepoint - Grew should fire with the completed char,
1012        // proving the partial bytes survived the reset.
1013        buf.append(&[0x8E, 0x89]).await;
1014        let grew: Vec<String> = all_grew(&builder);
1015        assert_eq!(grew.last().map(String::as_str), Some("\u{1F389}"));
1016    }
1017
1018    // --- OutputBuffer::snapshot_tail ---------------------
1019
1020    #[tokio::test]
1021    async fn snapshot_tail_returns_truncated_tail() {
1022        let buf = OutputBuffer::for_tests();
1023        buf.append(b"hello world").await;
1024        let tail = buf.snapshot_tail(5).await;
1025        assert_eq!(tail, "...world");
1026    }
1027
1028    #[tokio::test]
1029    async fn snapshot_tail_full_content_when_short() {
1030        let buf = OutputBuffer::for_tests();
1031        buf.append(b"hi").await;
1032        let tail = buf.snapshot_tail(80).await;
1033        assert_eq!(tail, "hi");
1034    }
1035
1036    // --- check_fail_in_buffer ----------------------------
1037
1038    #[test]
1039    fn check_fail_in_buffer_regex_match() {
1040        let fp = FailPattern::Regex(Regex::new(r"ERROR").unwrap());
1041        let hit = check_fail_in_buffer("some ERROR here", &fp).unwrap();
1042        assert_eq!(hit.pattern, "ERROR");
1043        assert_eq!(hit.matched_text, "ERROR");
1044    }
1045
1046    #[test]
1047    fn check_fail_in_buffer_regex_no_match() {
1048        let fp = FailPattern::Regex(Regex::new(r"ERROR").unwrap());
1049        assert!(check_fail_in_buffer("all good", &fp).is_none());
1050    }
1051
1052    #[test]
1053    fn check_fail_in_buffer_literal_match() {
1054        let fp = FailPattern::Literal("FATAL".to_string());
1055        let hit = check_fail_in_buffer("got FATAL crash", &fp).unwrap();
1056        assert_eq!(hit.pattern, "FATAL");
1057        assert_eq!(hit.matched_text, "FATAL");
1058    }
1059
1060    #[test]
1061    fn check_fail_in_buffer_literal_no_match() {
1062        let fp = FailPattern::Literal("FATAL".to_string());
1063        assert!(check_fail_in_buffer("all good", &fp).is_none());
1064    }
1065
1066    // --- multimatch_scan / drain_to ----------------------
1067
1068    #[tokio::test]
1069    async fn multimatch_scan_finds_literal_and_regex_without_drain() {
1070        let (buf, _builder, _rx) = wired_buffer();
1071        buf.append(
1072            b"job-a: started\njob-b: started\njob-a: complete (id=17)\njob-b: complete (id=23)\n",
1073        )
1074        .await;
1075        let block_entry = 0;
1076
1077        let re_a = RegexBuilder::new(r"^job-a: complete \(id=\d+\)$")
1078            .multi_line(true)
1079            .crlf(true)
1080            .build()
1081            .unwrap();
1082        let re_b = RegexBuilder::new(r"^job-b: complete \(id=\d+\)$")
1083            .multi_line(true)
1084            .crlf(true)
1085            .build()
1086            .unwrap();
1087
1088        let mut slots = vec![
1089            PatternSlot::regex("^job-a: complete \\(id=\\d+\\)$".to_string(), re_a),
1090            PatternSlot::regex("^job-b: complete \\(id=\\d+\\)$".to_string(), re_b),
1091        ];
1092
1093        let hits = buf.multimatch_scan(&mut slots, block_entry).await;
1094        assert!(hits[0].is_some(), "first slot should hit");
1095        assert!(hits[1].is_some(), "second slot should hit");
1096        // Critical: scan did not drain.
1097        let remaining_len = buf.remaining().await.len();
1098        assert_eq!(
1099            remaining_len, 78,
1100            "scan must be non-destructive (got len {remaining_len})"
1101        );
1102    }
1103
1104    #[tokio::test]
1105    async fn multimatch_scan_returns_absolute_offsets() {
1106        let buf = OutputBuffer::for_tests();
1107        // Prime + drain to advance base.
1108        buf.append(b"prefix ").await;
1109        let _ = buf.consume_literal("prefix ").await.unwrap();
1110        buf.append(b"target line\n").await;
1111
1112        let re = RegexBuilder::new(r"^target line$")
1113            .multi_line(true)
1114            .crlf(true)
1115            .build()
1116            .unwrap();
1117        let mut slots = vec![PatternSlot::regex("^target line$".to_string(), re)];
1118        let hits = buf.multimatch_scan(&mut slots, 7).await;
1119        let hit = hits[0].as_ref().expect("hit");
1120        assert_eq!(
1121            hit.start_abs, 7,
1122            "start is absolute, accounting for prior drain"
1123        );
1124        assert_eq!(hit.end_abs, 7 + "target line".len());
1125    }
1126
1127    #[tokio::test]
1128    async fn multimatch_scan_defers_partial_line_regex() {
1129        let buf = OutputBuffer::for_tests();
1130        // No trailing newline - `^line$` should be deferred.
1131        buf.append(b"line").await;
1132        let re = RegexBuilder::new(r"^line$")
1133            .multi_line(true)
1134            .crlf(true)
1135            .build()
1136            .unwrap();
1137        let mut slots = vec![PatternSlot::regex("^line$".to_string(), re)];
1138        let hits = buf.multimatch_scan(&mut slots, 0).await;
1139        assert!(
1140            hits[0].is_none(),
1141            "trailing-anchored regex must defer until newline"
1142        );
1143    }
1144
1145    #[tokio::test]
1146    async fn multimatch_scan_duplicate_patterns_match_independently() {
1147        let buf = OutputBuffer::for_tests();
1148        buf.append(b"hello world hello world\n").await;
1149        let mut slots = vec![
1150            PatternSlot::literal("hello".to_string()),
1151            PatternSlot::literal("hello".to_string()),
1152        ];
1153        let hits = buf.multimatch_scan(&mut slots, 0).await;
1154        // Both slots get a hit. Caller is responsible for deciding whether
1155        // they want distinct ranges - the scan reports the first occurrence
1156        // for each slot. R014 v1 accepts this; the test pins the contract.
1157        assert!(hits[0].is_some());
1158        assert!(hits[1].is_some());
1159    }
1160
1161    #[tokio::test]
1162    async fn drain_to_advances_base_and_drops_prefix_no_event() {
1163        let (buf, builder, _rx) = wired_buffer();
1164        buf.append(b"abc\ndef\n").await;
1165        let grew_count_before = all_grew(&builder).len();
1166        buf.drain_to(4).await; // drop "abc\n"
1167        let remaining = buf.remaining().await;
1168        assert_eq!(remaining, b"def\n");
1169        assert_eq!(
1170            all_grew(&builder).len(),
1171            grew_count_before,
1172            "drain_to must not emit any buffer event"
1173        );
1174    }
1175
1176    #[tokio::test]
1177    async fn drain_to_is_noop_when_offset_equals_base() {
1178        let buf = OutputBuffer::for_tests();
1179        buf.append(b"abc\n").await;
1180        buf.drain_to(0).await;
1181        assert_eq!(buf.remaining().await, b"abc\n");
1182    }
1183}