Skip to main content

relux_runtime/vm/
buffer.rs

1use std::collections::HashMap;
2use std::sync::Arc;
3
4use bytes::BytesMut;
5use regex::Regex;
6use tokio::sync::Mutex;
7use tokio::sync::Notify;
8
9use crate::observe::event_log::BufferSnapshot;
10use crate::vm::context::FailPattern;
11
12// ─── FailPatternHit ─────────────────────────────────────────────
13
14/// A fail pattern matched in the output buffer.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct FailPatternHit {
17    /// The pattern string that was being watched for (regex source or literal).
18    pub(crate) pattern: String,
19    /// The actual text in the buffer that matched.
20    pub(crate) matched_text: String,
21}
22
23// ─── Constants ──────────────────────────────────────────────────
24
25const BUFFER_PREFIX_LEN: usize = 40;
26const BUFFER_SUFFIX_LEN: usize = 40;
27pub(crate) const BUFFER_TAIL_LEN: usize = 80;
28
29// ─── Truncation helpers ─────────────────────────────────────────
30
31fn truncate_before(s: &str, max: usize) -> String {
32    if s.len() <= max {
33        s.to_string()
34    } else {
35        let start = s.ceil_char_boundary(s.len() - max);
36        format!("...{}", &s[start..])
37    }
38}
39
40fn truncate_after(s: &str, max: usize) -> String {
41    if s.len() <= max {
42        s.to_string()
43    } else {
44        let end = s.floor_char_boundary(max);
45        format!("{}...", &s[..end])
46    }
47}
48
49pub(crate) fn regex_error_summary(e: &regex::Error) -> String {
50    let full = e.to_string();
51    full.lines()
52        .rev()
53        .find(|l| !l.is_empty())
54        .unwrap_or(&full)
55        .strip_prefix("error: ")
56        .unwrap_or(&full)
57        .to_string()
58}
59
60// ─── Match Types ────────────────────────────────────────────────
61
62/// Marker trait for match payload types.
63pub trait MatchKind {}
64
65/// Payload for a literal match.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct LiteralMatch(pub String);
68impl MatchKind for LiteralMatch {}
69
70/// Payload for a regex match (capture groups by index).
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct RegexMatch(pub HashMap<String, String>);
73impl MatchKind for RegexMatch {}
74
75/// A match result with absolute byte offsets and typed payload.
76#[derive(Debug, Clone)]
77pub struct Match<T: MatchKind> {
78    /// Absolute byte offset of match start (accounts for all prior truncations).
79    pub start: usize,
80    /// Absolute byte offset of match end.
81    pub end: usize,
82    /// Bytes consumed (everything up to and including the match, relative to current buffer).
83    pub consumed: usize,
84    /// The matched content.
85    pub value: T,
86}
87
88// ─── OutputBuffer ───────────────────────────────────────────────
89
90struct BufferInner {
91    data: BytesMut,
92    base: usize,
93}
94
95#[derive(Clone)]
96pub struct OutputBuffer {
97    inner: Arc<Mutex<BufferInner>>,
98    pub(crate) notify: Arc<Notify>,
99    recv_pending: Arc<Mutex<BytesMut>>,
100}
101
102impl Default for OutputBuffer {
103    fn default() -> Self {
104        Self::new()
105    }
106}
107
108impl OutputBuffer {
109    pub fn new() -> Self {
110        Self {
111            inner: Arc::new(Mutex::new(BufferInner {
112                data: BytesMut::new(),
113                base: 0,
114            })),
115            notify: Arc::new(Notify::new()),
116            recv_pending: Arc::new(Mutex::new(BytesMut::new())),
117        }
118    }
119
120    pub async fn append(&self, bytes: &[u8]) {
121        self.inner.lock().await.data.extend_from_slice(bytes);
122        self.recv_pending.lock().await.extend_from_slice(bytes);
123        self.notify.notify_waiters();
124    }
125
126    pub async fn drain_recv(&self) -> Option<String> {
127        let mut pending = self.recv_pending.lock().await;
128        if pending.is_empty() {
129            return None;
130        }
131        let bytes = pending.split();
132        Some(String::from_utf8_lossy(&bytes).to_string())
133    }
134
135    /// Find literal, extract truncated context, drain via split_to. One lock.
136    /// Returns Match + BufferSnapshot for event emission.
137    pub async fn consume_literal(
138        &self,
139        needle: &str,
140    ) -> Option<(Match<LiteralMatch>, BufferSnapshot)> {
141        let mut inner = self.inner.lock().await;
142        let text = String::from_utf8_lossy(&inner.data);
143        let pos = text.find(needle)?;
144        let end_pos = pos + needle.len();
145
146        let before_raw = &text[..pos];
147        let after_raw = &text[end_pos..];
148        let snapshot = BufferSnapshot::Match {
149            before: truncate_before(before_raw, BUFFER_PREFIX_LEN),
150            matched: needle.to_string(),
151            after: truncate_after(after_raw, BUFFER_SUFFIX_LEN),
152        };
153
154        let consumed = end_pos;
155        let m = Match {
156            start: inner.base + pos,
157            end: inner.base + end_pos,
158            consumed,
159            value: LiteralMatch(needle.to_string()),
160        };
161
162        drop(text);
163        let _ = inner.data.split_to(end_pos);
164        inner.base += end_pos;
165
166        Some((m, snapshot))
167    }
168
169    /// Find regex, extract truncated context, drain via split_to. One lock.
170    ///
171    /// Guards against partial-line matches: if the match ends at the buffer
172    /// boundary and the buffer does not end with a newline, the last line may
173    /// still be arriving. In that case we return `None` so the caller waits
174    /// for more data rather than consuming an incomplete line.
175    pub async fn consume_regex(&self, re: &Regex) -> Option<(Match<RegexMatch>, BufferSnapshot)> {
176        let mut inner = self.inner.lock().await;
177        let text = String::from_utf8_lossy(&inner.data);
178        let cap = re.captures(&text)?;
179        let whole = cap.get(0)?;
180        let pos = whole.start();
181        let end_pos = whole.end();
182
183        if is_partial_line_match(re, end_pos, &text) {
184            return None;
185        }
186
187        let matched_str = whole.as_str().to_string();
188
189        let before_raw = &text[..pos];
190        let after_raw = &text[end_pos..];
191        let snapshot = BufferSnapshot::Match {
192            before: truncate_before(before_raw, BUFFER_PREFIX_LEN),
193            matched: matched_str.clone(),
194            after: truncate_after(after_raw, BUFFER_SUFFIX_LEN),
195        };
196
197        let mut captures = HashMap::new();
198        for i in 0..cap.len() {
199            if let Some(m) = cap.get(i) {
200                captures.insert(i.to_string(), m.as_str().to_string());
201            }
202        }
203
204        let consumed = end_pos;
205        let m = Match {
206            start: inner.base + pos,
207            end: inner.base + end_pos,
208            consumed,
209            value: RegexMatch(captures),
210        };
211
212        drop(text);
213        let _ = inner.data.split_to(end_pos);
214        inner.base += end_pos;
215
216        Some((m, snapshot))
217    }
218
219    /// Check fail pattern against buffer, then try to consume literal — under one lock.
220    /// Returns Err if fail pattern found, Ok(Some) if literal consumed, Ok(None) if not found.
221    pub async fn fail_check_consume_literal(
222        &self,
223        needle: &str,
224        fail_pattern: Option<&FailPattern>,
225    ) -> Result<Option<(Match<LiteralMatch>, BufferSnapshot)>, FailPatternHit> {
226        let mut inner = self.inner.lock().await;
227        let text = String::from_utf8_lossy(&inner.data);
228
229        // Check fail pattern first
230        if let Some(fp) = fail_pattern
231            && let Some(hit) = check_fail_in_buffer(&text, fp)
232        {
233            return Err(hit);
234        }
235
236        // Try to consume the literal
237        let Some(pos) = text.find(needle) else {
238            return Ok(None);
239        };
240        let end_pos = pos + needle.len();
241
242        let before_raw = &text[..pos];
243        let after_raw = &text[end_pos..];
244        let snapshot = BufferSnapshot::Match {
245            before: truncate_before(before_raw, BUFFER_PREFIX_LEN),
246            matched: needle.to_string(),
247            after: truncate_after(after_raw, BUFFER_SUFFIX_LEN),
248        };
249
250        let consumed = end_pos;
251        let m = Match {
252            start: inner.base + pos,
253            end: inner.base + end_pos,
254            consumed,
255            value: LiteralMatch(needle.to_string()),
256        };
257
258        drop(text);
259        let _ = inner.data.split_to(end_pos);
260        inner.base += end_pos;
261
262        Ok(Some((m, snapshot)))
263    }
264
265    /// Check fail pattern against buffer, then try to consume regex — under one lock.
266    /// Returns Err if fail pattern found, Ok(Some) if regex consumed, Ok(None) if not found.
267    pub async fn fail_check_consume_regex(
268        &self,
269        re: &Regex,
270        fail_pattern: Option<&FailPattern>,
271    ) -> Result<Option<(Match<RegexMatch>, BufferSnapshot)>, FailPatternHit> {
272        let mut inner = self.inner.lock().await;
273        let text = String::from_utf8_lossy(&inner.data);
274
275        // Check fail pattern first
276        if let Some(fp) = fail_pattern
277            && let Some(hit) = check_fail_in_buffer(&text, fp)
278        {
279            return Err(hit);
280        }
281
282        // Try to consume the regex
283        let Some(cap) = re.captures(&text) else {
284            return Ok(None);
285        };
286        let Some(whole) = cap.get(0) else {
287            return Ok(None);
288        };
289        let pos = whole.start();
290        let end_pos = whole.end();
291
292        if is_partial_line_match(re, end_pos, &text) {
293            return Ok(None);
294        }
295
296        let matched_str = whole.as_str().to_string();
297
298        let before_raw = &text[..pos];
299        let after_raw = &text[end_pos..];
300        let snapshot = BufferSnapshot::Match {
301            before: truncate_before(before_raw, BUFFER_PREFIX_LEN),
302            matched: matched_str.clone(),
303            after: truncate_after(after_raw, BUFFER_SUFFIX_LEN),
304        };
305
306        let mut captures = HashMap::new();
307        for i in 0..cap.len() {
308            if let Some(m) = cap.get(i) {
309                captures.insert(i.to_string(), m.as_str().to_string());
310            }
311        }
312
313        let consumed = end_pos;
314        let m = Match {
315            start: inner.base + pos,
316            end: inner.base + end_pos,
317            consumed,
318            value: RegexMatch(captures),
319        };
320
321        drop(text);
322        let _ = inner.data.split_to(end_pos);
323        inner.base += end_pos;
324
325        Ok(Some((m, snapshot)))
326    }
327
328    /// Check fail pattern against current buffer (peek only, no drain).
329    pub async fn check_fail_pattern(
330        &self,
331        fail_pattern: Option<&FailPattern>,
332    ) -> Option<FailPatternHit> {
333        let fp = fail_pattern?;
334        let inner = self.inner.lock().await;
335        let text = String::from_utf8_lossy(&inner.data);
336        check_fail_in_buffer(&text, fp)
337    }
338
339    /// Drain all buffered data, advancing base.
340    pub async fn clear(&self) {
341        let mut inner = self.inner.lock().await;
342        let len = inner.data.len();
343        let _ = inner.data.split_to(len);
344        inner.base += len;
345    }
346
347    /// Return a BufferSnapshot::Tail of the current buffer (last `n` chars).
348    pub async fn snapshot_tail(&self, n: usize) -> BufferSnapshot {
349        let inner = self.inner.lock().await;
350        let text = String::from_utf8_lossy(&inner.data);
351        BufferSnapshot::Tail {
352            content: truncate_before(&text, n),
353        }
354    }
355
356    /// Return remaining unmatched buffer data.
357    pub async fn remaining(&self) -> Vec<u8> {
358        let inner = self.inner.lock().await;
359        inner.data.to_vec()
360    }
361}
362
363/// Returns `true` if a `$`-anchored regex matched at the buffer boundary
364/// where the buffer does not end with a newline — meaning the last line may
365/// still be arriving and `$` matched end-of-string rather than end-of-line.
366///
367/// Only applies when the regex source ends with an explicit `$` anchor.
368/// Patterns without `$` (e.g. prompt matching with `^relux> `) are never
369/// deferred, since they don't depend on line completeness.
370fn is_partial_line_match(re: &Regex, match_end: usize, text: &str) -> bool {
371    re.as_str().ends_with('$') && match_end == text.len() && !text.ends_with('\n')
372}
373
374/// Check if a fail pattern matches in the given text. Returns (pattern_str, matched_text).
375fn check_fail_in_buffer(text: &str, pattern: &FailPattern) -> Option<FailPatternHit> {
376    match pattern {
377        FailPattern::Regex(re) => {
378            let m = re.find(text)?;
379            Some(FailPatternHit {
380                pattern: re.as_str().to_string(),
381                matched_text: m.as_str().to_string(),
382            })
383        }
384        FailPattern::Literal(s) => {
385            text.find(s.as_str())?;
386            Some(FailPatternHit {
387                pattern: s.clone(),
388                matched_text: s.clone(),
389            })
390        }
391    }
392}
393
394// ─── Tests ──────────────────────────────────────────────────────
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399    use regex::RegexBuilder;
400
401    // ── truncate_before ──────────────────────────────────────────────
402
403    #[test]
404    fn truncate_before_short_string_unchanged() {
405        assert_eq!(truncate_before("hello", 10), "hello");
406    }
407
408    #[test]
409    fn truncate_before_exact_length_unchanged() {
410        assert_eq!(truncate_before("hello", 5), "hello");
411    }
412
413    #[test]
414    fn truncate_before_keeps_last_n_chars() {
415        assert_eq!(truncate_before("hello world", 5), "...world");
416    }
417
418    #[test]
419    fn truncate_before_empty_string() {
420        assert_eq!(truncate_before("", 5), "");
421    }
422
423    #[test]
424    fn truncate_before_max_zero() {
425        assert_eq!(truncate_before("hello", 0), "...");
426    }
427
428    // ── truncate_after ───────────────────────────────────────────────
429
430    #[test]
431    fn truncate_after_short_string_unchanged() {
432        assert_eq!(truncate_after("hello", 10), "hello");
433    }
434
435    #[test]
436    fn truncate_after_exact_length_unchanged() {
437        assert_eq!(truncate_after("hello", 5), "hello");
438    }
439
440    #[test]
441    fn truncate_after_keeps_first_n_chars() {
442        assert_eq!(truncate_after("hello world", 5), "hello...");
443    }
444
445    #[test]
446    fn truncate_after_empty_string() {
447        assert_eq!(truncate_after("", 5), "");
448    }
449
450    #[test]
451    fn truncate_after_max_zero() {
452        assert_eq!(truncate_after("hello", 0), "...");
453    }
454
455    // ── regex_error_summary ──────────────────────────────────────────
456
457    #[test]
458    #[allow(clippy::invalid_regex)]
459    fn regex_error_summary_extracts_last_line() {
460        let err = Regex::new("(unclosed").unwrap_err();
461        let summary = regex_error_summary(&err);
462        assert!(!summary.is_empty());
463        assert!(!summary.starts_with("error: "));
464    }
465
466    #[test]
467    #[allow(clippy::invalid_regex)]
468    fn regex_error_summary_strips_error_prefix() {
469        let err = Regex::new("[invalid").unwrap_err();
470        let summary = regex_error_summary(&err);
471        assert!(!summary.starts_with("error: "));
472    }
473
474    // ── OutputBuffer::new ────────────────────────────────────────────
475
476    #[tokio::test]
477    async fn output_buffer_new_is_empty() {
478        let buf = OutputBuffer::new();
479        assert!(buf.remaining().await.is_empty());
480    }
481
482    // ── OutputBuffer::append + remaining ─────────────────────────────
483
484    #[tokio::test]
485    async fn output_buffer_append_and_remaining() {
486        let buf = OutputBuffer::new();
487        buf.append(b"hello ").await;
488        buf.append(b"world").await;
489        assert_eq!(buf.remaining().await, b"hello world");
490    }
491
492    #[tokio::test]
493    async fn output_buffer_append_empty_bytes() {
494        let buf = OutputBuffer::new();
495        buf.append(b"").await;
496        assert!(buf.remaining().await.is_empty());
497    }
498
499    // ── OutputBuffer::consume_literal ────────────────────────────────
500
501    #[tokio::test]
502    async fn consume_literal_basic() {
503        let buf = OutputBuffer::new();
504        buf.append(b"hello world").await;
505        let (m, snapshot) = buf.consume_literal("hello").await.unwrap();
506        assert_eq!(m.start, 0);
507        assert_eq!(m.end, 5);
508        assert_eq!(m.consumed, 5);
509        assert_eq!(m.value.0, "hello");
510        assert!(matches!(snapshot, BufferSnapshot::Match { .. }));
511        // Buffer should have " world" remaining
512        assert_eq!(buf.remaining().await, b" world");
513    }
514
515    #[tokio::test]
516    async fn consume_literal_drains_up_to_match_end() {
517        let buf = OutputBuffer::new();
518        buf.append(b"prefix MATCH suffix").await;
519        let (m, _) = buf.consume_literal("MATCH").await.unwrap();
520        assert_eq!(m.start, 7);
521        assert_eq!(m.end, 12);
522        assert_eq!(m.consumed, 12);
523        assert_eq!(buf.remaining().await, b" suffix");
524    }
525
526    #[tokio::test]
527    async fn consume_literal_not_found() {
528        let buf = OutputBuffer::new();
529        buf.append(b"hello world").await;
530        assert!(buf.consume_literal("xyz").await.is_none());
531        // Buffer unchanged
532        assert_eq!(buf.remaining().await, b"hello world");
533    }
534
535    #[tokio::test]
536    async fn consume_literal_absolute_offsets_after_drain() {
537        let buf = OutputBuffer::new();
538        buf.append(b"aaa bbb ccc").await;
539        // Consume "aaa"
540        let (m1, _) = buf.consume_literal("aaa").await.unwrap();
541        assert_eq!(m1.start, 0);
542        assert_eq!(m1.end, 3);
543        // Now consume "bbb" — absolute offsets should account for drained bytes
544        let (m2, _) = buf.consume_literal("bbb").await.unwrap();
545        assert_eq!(m2.start, 4);
546        assert_eq!(m2.end, 7);
547        // Remaining should be " ccc"
548        assert_eq!(buf.remaining().await, b" ccc");
549    }
550
551    #[tokio::test]
552    async fn consume_literal_snapshot_has_truncated_context() {
553        let buf = OutputBuffer::new();
554        buf.append(b"before MATCH after").await;
555        let (_, snapshot) = buf.consume_literal("MATCH").await.unwrap();
556        match snapshot {
557            BufferSnapshot::Match {
558                before,
559                matched,
560                after,
561            } => {
562                assert_eq!(before, "before ");
563                assert_eq!(matched, "MATCH");
564                assert_eq!(after, " after");
565            }
566            _ => panic!("expected BufferSnapshot::Match"),
567        }
568    }
569
570    // ── OutputBuffer::consume_regex ──────────────────────────────────
571
572    #[tokio::test]
573    async fn consume_regex_basic() {
574        let buf = OutputBuffer::new();
575        buf.append(b"abc 123 def").await;
576        let re = Regex::new(r"\d+").unwrap();
577        let (m, _) = buf.consume_regex(&re).await.unwrap();
578        assert_eq!(m.start, 4);
579        assert_eq!(m.end, 7);
580        assert_eq!(m.value.0.get("0").unwrap(), "123");
581        assert_eq!(buf.remaining().await, b" def");
582    }
583
584    #[tokio::test]
585    async fn consume_regex_with_captures() {
586        let buf = OutputBuffer::new();
587        buf.append(b"name: Alice age: 30\n").await;
588        let re = Regex::new(r"name: (\w+) age: (\d+)").unwrap();
589        let (m, _) = buf.consume_regex(&re).await.unwrap();
590        assert_eq!(m.start, 0);
591        assert_eq!(m.end, 19);
592        assert_eq!(m.value.0.get("0").unwrap(), "name: Alice age: 30");
593        assert_eq!(m.value.0.get("1").unwrap(), "Alice");
594        assert_eq!(m.value.0.get("2").unwrap(), "30");
595    }
596
597    #[tokio::test]
598    async fn consume_regex_not_found() {
599        let buf = OutputBuffer::new();
600        buf.append(b"hello world").await;
601        let re = Regex::new(r"\d+").unwrap();
602        assert!(buf.consume_regex(&re).await.is_none());
603        assert_eq!(buf.remaining().await, b"hello world");
604    }
605
606    #[tokio::test]
607    async fn consume_regex_absolute_offsets_after_drain() {
608        let buf = OutputBuffer::new();
609        buf.append(b"aaa 123 bbb 456\n").await;
610        let re = Regex::new(r"\d+").unwrap();
611        let (m1, _) = buf.consume_regex(&re).await.unwrap();
612        assert_eq!(m1.start, 4);
613        assert_eq!(m1.end, 7);
614        // After consuming "aaa 123", buffer has " bbb 456"
615        let (m2, _) = buf.consume_regex(&re).await.unwrap();
616        assert_eq!(m2.start, 12);
617        assert_eq!(m2.end, 15);
618    }
619
620    // ── Partial-line guard ─────────────────────────────────────────
621
622    #[tokio::test]
623    async fn consume_regex_defers_partial_line() {
624        // Simulate a partial delivery: no trailing newline
625        let buf = OutputBuffer::new();
626        buf.append(b"hello wor").await;
627        let re = RegexBuilder::new(r"^(.+)$")
628            .multi_line(true)
629            .build()
630            .unwrap();
631        // Should defer — the line might not be complete yet
632        assert!(buf.consume_regex(&re).await.is_none());
633        // Buffer unchanged
634        assert_eq!(buf.remaining().await, b"hello wor");
635
636        // Now the rest arrives
637        buf.append(b"ld\n").await;
638        let (m, _) = buf.consume_regex(&re).await.unwrap();
639        assert_eq!(m.value.0.get("0").unwrap(), "hello world");
640    }
641
642    #[tokio::test]
643    async fn consume_regex_allows_match_before_partial_tail() {
644        // A complete line followed by an incomplete one
645        let buf = OutputBuffer::new();
646        buf.append(b"first line\nsecond li").await;
647        let re = RegexBuilder::new(r"^(.+)$")
648            .multi_line(true)
649            .build()
650            .unwrap();
651        // Should match the complete first line (match end < buffer len)
652        let (m, _) = buf.consume_regex(&re).await.unwrap();
653        assert_eq!(m.value.0.get("1").unwrap(), "first line");
654    }
655
656    #[tokio::test]
657    async fn fail_check_consume_regex_defers_partial_line() {
658        let buf = OutputBuffer::new();
659        buf.append(b"partial data").await;
660        let re = RegexBuilder::new(r"^(.+)$")
661            .multi_line(true)
662            .build()
663            .unwrap();
664        let result = buf.fail_check_consume_regex(&re, None).await;
665        assert!(result.unwrap().is_none());
666
667        buf.append(b"\n").await;
668        let result = buf.fail_check_consume_regex(&re, None).await;
669        let (m, _) = result.unwrap().unwrap();
670        assert_eq!(m.value.0.get("0").unwrap(), "partial data");
671    }
672
673    // ── OutputBuffer::clear ─────────────────────────────────────────
674
675    #[tokio::test]
676    async fn clear_empties_buffer() {
677        let buf = OutputBuffer::new();
678        buf.append(b"hello world").await;
679        buf.clear().await;
680        assert!(buf.remaining().await.is_empty());
681    }
682
683    #[tokio::test]
684    async fn clear_advances_base_correctly() {
685        let buf = OutputBuffer::new();
686        buf.append(b"hello world").await;
687        buf.clear().await;
688        buf.append(b"abc 123\n").await;
689        let re = Regex::new(r"\d+").unwrap();
690        let (m, _) = buf.consume_regex(&re).await.unwrap();
691        // base should be 11 (from clear) + 4 (from "abc ") = absolute offset 15
692        assert_eq!(m.start, 15);
693        assert_eq!(m.end, 18);
694    }
695
696    // ── OutputBuffer::snapshot_tail ─────────────────────────────────
697
698    #[tokio::test]
699    async fn snapshot_tail_returns_tail() {
700        let buf = OutputBuffer::new();
701        buf.append(b"hello world").await;
702        let snapshot = buf.snapshot_tail(5).await;
703        match snapshot {
704            BufferSnapshot::Tail { content } => {
705                assert_eq!(content, "...world");
706            }
707            _ => panic!("expected Tail"),
708        }
709    }
710
711    #[tokio::test]
712    async fn snapshot_tail_full_content_when_short() {
713        let buf = OutputBuffer::new();
714        buf.append(b"hi").await;
715        let snapshot = buf.snapshot_tail(80).await;
716        match snapshot {
717            BufferSnapshot::Tail { content } => {
718                assert_eq!(content, "hi");
719            }
720            _ => panic!("expected Tail"),
721        }
722    }
723
724    // ── check_fail_in_buffer ────────────────────────────────────────
725
726    #[test]
727    fn check_fail_in_buffer_regex_match() {
728        let fp = FailPattern::Regex(Regex::new(r"ERROR").unwrap());
729        let hit = check_fail_in_buffer("some ERROR here", &fp).unwrap();
730        assert_eq!(hit.pattern, "ERROR");
731        assert_eq!(hit.matched_text, "ERROR");
732    }
733
734    #[test]
735    fn check_fail_in_buffer_regex_no_match() {
736        let fp = FailPattern::Regex(Regex::new(r"ERROR").unwrap());
737        assert!(check_fail_in_buffer("all good", &fp).is_none());
738    }
739
740    #[test]
741    fn check_fail_in_buffer_literal_match() {
742        let fp = FailPattern::Literal("FATAL".to_string());
743        let hit = check_fail_in_buffer("got FATAL crash", &fp).unwrap();
744        assert_eq!(hit.pattern, "FATAL");
745        assert_eq!(hit.matched_text, "FATAL");
746    }
747
748    #[test]
749    fn check_fail_in_buffer_literal_no_match() {
750        let fp = FailPattern::Literal("FATAL".to_string());
751        assert!(check_fail_in_buffer("all good", &fp).is_none());
752    }
753
754    // ── OutputBuffer::fail_check_consume_literal ────────────────────
755
756    #[tokio::test]
757    async fn fail_check_consume_literal_no_fail_pattern() {
758        let buf = OutputBuffer::new();
759        buf.append(b"hello world").await;
760        let result = buf.fail_check_consume_literal("hello", None).await;
761        let (m, _) = result.unwrap().unwrap();
762        assert_eq!(m.value.0, "hello");
763    }
764
765    #[tokio::test]
766    async fn fail_check_consume_literal_fail_pattern_not_matched() {
767        let buf = OutputBuffer::new();
768        buf.append(b"hello world").await;
769        let fp = FailPattern::Regex(Regex::new(r"ERROR").unwrap());
770        let result = buf.fail_check_consume_literal("hello", Some(&fp)).await;
771        let (m, _) = result.unwrap().unwrap();
772        assert_eq!(m.value.0, "hello");
773    }
774
775    #[tokio::test]
776    async fn fail_check_consume_literal_fail_pattern_triggers() {
777        let buf = OutputBuffer::new();
778        buf.append(b"ERROR: something broke").await;
779        let fp = FailPattern::Regex(Regex::new(r"ERROR").unwrap());
780        let result = buf.fail_check_consume_literal("broke", Some(&fp)).await;
781        let hit = result.unwrap_err();
782        assert_eq!(hit.pattern, "ERROR");
783        assert_eq!(hit.matched_text, "ERROR");
784        // Buffer unchanged — fail pattern short-circuits before consume
785        assert_eq!(buf.remaining().await, b"ERROR: something broke");
786    }
787
788    #[tokio::test]
789    async fn fail_check_consume_literal_target_not_found() {
790        let buf = OutputBuffer::new();
791        buf.append(b"hello world").await;
792        let result = buf.fail_check_consume_literal("xyz", None).await;
793        assert!(result.unwrap().is_none());
794    }
795
796    // ── OutputBuffer::fail_check_consume_regex ──────────────────────
797
798    #[tokio::test]
799    async fn fail_check_consume_regex_no_fail_pattern() {
800        let buf = OutputBuffer::new();
801        buf.append(b"abc 123 def").await;
802        let re = Regex::new(r"\d+").unwrap();
803        let result = buf.fail_check_consume_regex(&re, None).await;
804        let (m, _) = result.unwrap().unwrap();
805        assert_eq!(m.value.0.get("0").unwrap(), "123");
806    }
807
808    #[tokio::test]
809    async fn fail_check_consume_regex_fail_pattern_triggers() {
810        let buf = OutputBuffer::new();
811        buf.append(b"FATAL: abc 123").await;
812        let fp = FailPattern::Literal("FATAL".to_string());
813        let re = Regex::new(r"\d+").unwrap();
814        let result = buf.fail_check_consume_regex(&re, Some(&fp)).await;
815        let hit = result.unwrap_err();
816        assert_eq!(hit.pattern, "FATAL");
817        assert_eq!(hit.matched_text, "FATAL");
818        // Buffer unchanged
819        assert_eq!(buf.remaining().await, b"FATAL: abc 123");
820    }
821
822    #[tokio::test]
823    async fn fail_check_consume_regex_target_not_found() {
824        let buf = OutputBuffer::new();
825        buf.append(b"hello world").await;
826        let re = Regex::new(r"\d+").unwrap();
827        let result = buf.fail_check_consume_regex(&re, None).await;
828        assert!(result.unwrap().is_none());
829    }
830
831    // ── OutputBuffer::check_fail_pattern ─────────────────────────────
832
833    #[tokio::test]
834    async fn check_fail_pattern_none() {
835        let buf = OutputBuffer::new();
836        buf.append(b"ERROR here").await;
837        assert!(buf.check_fail_pattern(None).await.is_none());
838    }
839
840    #[tokio::test]
841    async fn check_fail_pattern_found() {
842        let buf = OutputBuffer::new();
843        buf.append(b"got ERROR output").await;
844        let fp = FailPattern::Regex(Regex::new(r"ERROR").unwrap());
845        let hit = buf.check_fail_pattern(Some(&fp)).await.unwrap();
846        assert_eq!(hit.pattern, "ERROR");
847        assert_eq!(hit.matched_text, "ERROR");
848    }
849
850    #[tokio::test]
851    async fn check_fail_pattern_not_found() {
852        let buf = OutputBuffer::new();
853        buf.append(b"all good").await;
854        let fp = FailPattern::Regex(Regex::new(r"ERROR").unwrap());
855        assert!(buf.check_fail_pattern(Some(&fp)).await.is_none());
856    }
857}