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#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct FailPatternHit {
19 pub(crate) pattern: String,
21 pub(crate) is_regex: bool,
23 pub(crate) matched_text: String,
25}
26
27pub type MatchSlices = (String, String, String);
37
38fn 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: ®ex::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
72pub trait MatchKind {}
76
77#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct LiteralMatch(pub String);
80impl MatchKind for LiteralMatch {}
81
82#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct RegexMatch(pub HashMap<String, String>);
85impl MatchKind for RegexMatch {}
86
87#[derive(Debug, Clone)]
89pub struct Match<T: MatchKind> {
90 pub start: usize,
92 pub end: usize,
94 pub consumed: usize,
96 pub value: T,
98}
99
100#[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#[derive(Debug, Clone)]
138pub struct MultiMatchHit {
139 pub matched_text: String,
142 pub start_abs: usize,
144 pub end_abs: usize,
146 pub before: String,
150 pub after: String,
152}
153
154struct BufferInner {
157 decoded: String,
161 base: usize,
165 utf8: Utf8Stream,
168}
169
170#[derive(Clone)]
171pub struct OutputBuffer {
172 inner: Arc<Mutex<BufferInner>>,
173 pub(crate) notify: Arc<Notify>,
174 log: Option<StructuredLogBuilder>,
178 shell_name: String,
179 shell_marker: String,
180}
181
182impl OutputBuffer {
183 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 #[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 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 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 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 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 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 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 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 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 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 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 pub async fn base_offset(&self) -> usize {
479 let inner = self.inner.lock().unwrap();
480 inner.base
481 }
482
483 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 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 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 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 pub async fn remaining(&self) -> Vec<u8> {
539 let inner = self.inner.lock().unwrap();
540 inner.decoded.as_bytes().to_vec()
541 }
542}
543
544fn 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
555fn 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
596fn 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#[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 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 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 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 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 #[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 #[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 #[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 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 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 #[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 #[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 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 #[test]
932 fn has_trailing_anchor_unescaped() {
933 assert!(super::has_trailing_anchor("foo$"));
934 assert!(super::has_trailing_anchor(r"^(.+)$"));
935 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 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 #[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 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 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 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 buf.append(&[0xF0, 0x9F]).await;
1008 let _ = buf.clear().await;
1009 let consumed = last_reset(&builder).expect("reset event");
1010 assert_eq!(consumed, "");
1011 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 #[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 #[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 #[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 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 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 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 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; 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}