ralph_core/
event_parser.rs1use ralph_proto::{Event, HatId};
10
11fn strip_ansi(s: &str) -> String {
16 let bytes = s.as_bytes();
17 let mut result = Vec::with_capacity(bytes.len());
18 let mut i = 0;
19
20 while i < bytes.len() {
21 if bytes[i] == 0x1b {
22 i += 1;
24 if i >= bytes.len() {
25 break;
26 }
27
28 match bytes[i] {
29 b'[' => {
30 i += 1;
32 while i < bytes.len() && !(0x40..=0x7E).contains(&bytes[i]) {
33 i += 1;
34 }
35 if i < bytes.len() {
36 i += 1; }
38 }
39 b']' => {
40 i += 1;
42 while i < bytes.len() {
43 if bytes[i] == 0x07 {
44 i += 1;
45 break;
46 }
47 if bytes[i] == 0x1b && i + 1 < bytes.len() && bytes[i + 1] == b'\\' {
48 i += 2;
49 break;
50 }
51 i += 1;
52 }
53 }
54 _ => {
55 i += 1;
57 }
58 }
59 } else {
60 result.push(bytes[i]);
61 i += 1;
62 }
63 }
64
65 String::from_utf8_lossy(&result).into_owned()
66}
67
68#[derive(Debug, Clone, PartialEq)]
70pub struct BackpressureEvidence {
71 pub tests_passed: bool,
72 pub lint_passed: bool,
73 pub typecheck_passed: bool,
74}
75
76impl BackpressureEvidence {
77 pub fn all_passed(&self) -> bool {
79 self.tests_passed && self.lint_passed && self.typecheck_passed
80 }
81}
82
83#[derive(Debug, Clone, PartialEq)]
88pub struct ReviewEvidence {
89 pub tests_passed: bool,
90 pub build_passed: bool,
91}
92
93impl ReviewEvidence {
94 pub fn is_verified(&self) -> bool {
98 self.tests_passed && self.build_passed
99 }
100}
101
102#[derive(Debug, Default)]
104pub struct EventParser {
105 source: Option<HatId>,
107}
108
109impl EventParser {
110 pub fn new() -> Self {
112 Self::default()
113 }
114
115 pub fn with_source(mut self, source: impl Into<HatId>) -> Self {
117 self.source = Some(source.into());
118 self
119 }
120
121 pub fn parse(&self, output: &str) -> Vec<Event> {
125 let mut events = Vec::new();
126 let mut remaining = output;
127
128 while let Some(start_idx) = remaining.find("<event ") {
129 let after_start = &remaining[start_idx..];
130
131 let Some(tag_end) = after_start.find('>') else {
133 remaining = &remaining[start_idx + 7..];
134 continue;
135 };
136
137 let opening_tag = &after_start[..tag_end + 1];
138
139 let topic = Self::extract_attr(opening_tag, "topic");
141 let target = Self::extract_attr(opening_tag, "target");
142
143 let Some(topic) = topic else {
144 remaining = &remaining[start_idx + tag_end + 1..];
145 continue;
146 };
147
148 let content_start = &after_start[tag_end + 1..];
150 let Some(close_idx) = content_start.find("</event>") else {
151 remaining = &remaining[start_idx + tag_end + 1..];
152 continue;
153 };
154
155 let payload = content_start[..close_idx].trim().to_string();
156
157 let mut event = Event::new(topic, payload);
158
159 if let Some(source) = &self.source {
160 event = event.with_source(source.clone());
161 }
162
163 if let Some(target) = target {
164 event = event.with_target(target);
165 }
166
167 events.push(event);
168
169 let total_consumed = start_idx + tag_end + 1 + close_idx + 8; remaining = &remaining[total_consumed..];
172 }
173
174 events
175 }
176
177 fn extract_attr(tag: &str, attr: &str) -> Option<String> {
179 let pattern = format!("{attr}=\"");
180 let start = tag.find(&pattern)?;
181 let value_start = start + pattern.len();
182 let rest = &tag[value_start..];
183 let end = rest.find('"')?;
184 Some(rest[..end].to_string())
185 }
186
187 pub fn parse_backpressure_evidence(payload: &str) -> Option<BackpressureEvidence> {
199 let clean_payload = strip_ansi(payload);
201
202 let tests_passed = clean_payload.contains("tests: pass");
203 let lint_passed = clean_payload.contains("lint: pass");
204 let typecheck_passed = clean_payload.contains("typecheck: pass");
205
206 if clean_payload.contains("tests:")
208 || clean_payload.contains("lint:")
209 || clean_payload.contains("typecheck:")
210 {
211 Some(BackpressureEvidence {
212 tests_passed,
213 lint_passed,
214 typecheck_passed,
215 })
216 } else {
217 None
218 }
219 }
220
221 pub fn parse_review_evidence(payload: &str) -> Option<ReviewEvidence> {
231 let clean_payload = strip_ansi(payload);
232
233 let tests_passed = clean_payload.contains("tests: pass");
234 let build_passed = clean_payload.contains("build: pass");
235
236 if clean_payload.contains("tests:") || clean_payload.contains("build:") {
238 Some(ReviewEvidence {
239 tests_passed,
240 build_passed,
241 })
242 } else {
243 None
244 }
245 }
246
247 pub fn contains_promise(output: &str, promise: &str) -> bool {
255 if Self::promise_in_event_tags(output, promise) {
257 return false;
258 }
259 let stripped = Self::strip_event_tags(output);
260 stripped.contains(promise)
261 }
262
263 pub fn promise_in_event_tags(output: &str, promise: &str) -> bool {
265 let mut remaining = output;
266
267 while let Some(start_idx) = remaining.find("<event ") {
268 let after_start = &remaining[start_idx..];
269
270 let Some(tag_end) = after_start.find('>') else {
272 remaining = &remaining[start_idx + 7..];
273 continue;
274 };
275
276 let content_start = &after_start[tag_end + 1..];
278 let Some(close_idx) = content_start.find("</event>") else {
279 remaining = &remaining[start_idx + tag_end + 1..];
280 continue;
281 };
282
283 let payload = &content_start[..close_idx];
284 if payload.contains(promise) {
285 return true;
286 }
287
288 let total_consumed = start_idx + tag_end + 1 + close_idx + 8;
290 remaining = &remaining[total_consumed..];
291 }
292
293 false
294 }
295
296 fn strip_event_tags(output: &str) -> String {
301 let mut result = String::with_capacity(output.len());
302 let mut remaining = output;
303
304 while let Some(start_idx) = remaining.find("<event ") {
305 result.push_str(&remaining[..start_idx]);
307
308 let after_start = &remaining[start_idx..];
309
310 if let Some(close_idx) = after_start.find("</event>") {
312 remaining = &after_start[close_idx + 8..]; } else {
315 result.push_str(after_start);
317 remaining = "";
318 break;
319 }
320 }
321
322 result.push_str(remaining);
324 result
325 }
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331
332 #[test]
333 fn test_parse_single_event() {
334 let output = r#"
335Some preamble text.
336<event topic="impl.done">
337Implemented the authentication module.
338</event>
339Some trailing text.
340"#;
341 let parser = EventParser::new();
342 let events = parser.parse(output);
343
344 assert_eq!(events.len(), 1);
345 assert_eq!(events[0].topic.as_str(), "impl.done");
346 assert!(events[0].payload.contains("authentication module"));
347 }
348
349 #[test]
350 fn test_parse_event_with_target() {
351 let output = r#"<event topic="handoff" target="reviewer">Please review</event>"#;
352 let parser = EventParser::new();
353 let events = parser.parse(output);
354
355 assert_eq!(events.len(), 1);
356 assert_eq!(events[0].target.as_ref().unwrap().as_str(), "reviewer");
357 }
358
359 #[test]
360 fn test_parse_multiple_events() {
361 let output = r#"
362<event topic="impl.started">Starting work</event>
363Working on implementation...
364<event topic="impl.done">Finished</event>
365"#;
366 let parser = EventParser::new();
367 let events = parser.parse(output);
368
369 assert_eq!(events.len(), 2);
370 assert_eq!(events[0].topic.as_str(), "impl.started");
371 assert_eq!(events[1].topic.as_str(), "impl.done");
372 }
373
374 #[test]
375 fn test_parse_with_source() {
376 let output = r#"<event topic="impl.done">Done</event>"#;
377 let parser = EventParser::new().with_source("implementer");
378 let events = parser.parse(output);
379
380 assert_eq!(events[0].source.as_ref().unwrap().as_str(), "implementer");
381 }
382
383 #[test]
384 fn test_no_events() {
385 let output = "Just regular output with no events.";
386 let parser = EventParser::new();
387 let events = parser.parse(output);
388
389 assert!(events.is_empty());
390 }
391
392 #[test]
393 fn test_contains_promise() {
394 assert!(EventParser::contains_promise(
395 "LOOP_COMPLETE",
396 "LOOP_COMPLETE"
397 ));
398 assert!(EventParser::contains_promise(
399 "prefix LOOP_COMPLETE suffix",
400 "LOOP_COMPLETE"
401 ));
402 assert!(!EventParser::contains_promise(
403 "No promise here",
404 "LOOP_COMPLETE"
405 ));
406 }
407
408 #[test]
409 fn test_contains_promise_ignores_event_payloads() {
410 let output = r#"<event topic="build.task">Fix LOOP_COMPLETE detection</event>"#;
412 assert!(!EventParser::contains_promise(output, "LOOP_COMPLETE"));
413
414 let output = r#"<event topic="build.task">
416## Task: Fix completion promise detection
417- Given LOOP_COMPLETE appears inside an event tag
418- Then it should be ignored
419</event>"#;
420 assert!(!EventParser::contains_promise(output, "LOOP_COMPLETE"));
421 }
422
423 #[test]
424 fn test_contains_promise_detects_outside_events() {
425 let output = r#"<event topic="build.done">Task complete</event>
427All done! LOOP_COMPLETE"#;
428 assert!(EventParser::contains_promise(output, "LOOP_COMPLETE"));
429
430 let output = r#"LOOP_COMPLETE
432<event topic="summary">Final summary</event>"#;
433 assert!(EventParser::contains_promise(output, "LOOP_COMPLETE"));
434 }
435
436 #[test]
437 fn test_contains_promise_mixed_content() {
438 let output = r#"Working on task...
440<event topic="build.task">Fix LOOP_COMPLETE bug</event>
441Still working..."#;
442 assert!(!EventParser::contains_promise(output, "LOOP_COMPLETE"));
443
444 let output = r#"All tasks done. LOOP_COMPLETE
447<event topic="summary">Completed LOOP_COMPLETE task</event>"#;
448 assert!(!EventParser::contains_promise(output, "LOOP_COMPLETE"));
449 }
450
451 #[test]
452 fn test_promise_in_event_tags() {
453 let output = r#"<event topic="build.task">Fix LOOP_COMPLETE bug</event>"#;
455 assert!(EventParser::promise_in_event_tags(output, "LOOP_COMPLETE"));
456
457 let output = r#"<event topic="build.done">Task complete</event>"#;
459 assert!(!EventParser::promise_in_event_tags(output, "LOOP_COMPLETE"));
460
461 let output = "Just regular text with LOOP_COMPLETE";
463 assert!(!EventParser::promise_in_event_tags(output, "LOOP_COMPLETE"));
464
465 let output = r#"<event topic="a">first</event>
467<event topic="b">contains LOOP_COMPLETE</event>"#;
468 assert!(EventParser::promise_in_event_tags(output, "LOOP_COMPLETE"));
469 }
470
471 #[test]
472 fn test_strip_event_tags() {
473 let output = r#"before <event topic="test">payload</event> after"#;
475 let stripped = EventParser::strip_event_tags(output);
476 assert_eq!(stripped, "before after");
477 assert!(!stripped.contains("payload"));
478
479 let output =
481 r#"start <event topic="a">one</event> middle <event topic="b">two</event> end"#;
482 let stripped = EventParser::strip_event_tags(output);
483 assert_eq!(stripped, "start middle end");
484
485 let output = "just plain text";
487 let stripped = EventParser::strip_event_tags(output);
488 assert_eq!(stripped, "just plain text");
489 }
490
491 #[test]
492 fn test_parse_backpressure_evidence_all_pass() {
493 let payload = "tests: pass\nlint: pass\ntypecheck: pass";
494 let evidence = EventParser::parse_backpressure_evidence(payload).unwrap();
495 assert!(evidence.tests_passed);
496 assert!(evidence.lint_passed);
497 assert!(evidence.typecheck_passed);
498 assert!(evidence.all_passed());
499 }
500
501 #[test]
502 fn test_parse_backpressure_evidence_some_fail() {
503 let payload = "tests: pass\nlint: fail\ntypecheck: pass";
504 let evidence = EventParser::parse_backpressure_evidence(payload).unwrap();
505 assert!(evidence.tests_passed);
506 assert!(!evidence.lint_passed);
507 assert!(evidence.typecheck_passed);
508 assert!(!evidence.all_passed());
509 }
510
511 #[test]
512 fn test_parse_backpressure_evidence_missing() {
513 let payload = "Task completed successfully";
514 let evidence = EventParser::parse_backpressure_evidence(payload);
515 assert!(evidence.is_none());
516 }
517
518 #[test]
519 fn test_parse_backpressure_evidence_partial() {
520 let payload = "tests: pass\nSome other text";
521 let evidence = EventParser::parse_backpressure_evidence(payload).unwrap();
522 assert!(evidence.tests_passed);
523 assert!(!evidence.lint_passed);
524 assert!(!evidence.typecheck_passed);
525 assert!(!evidence.all_passed());
526 }
527
528 #[test]
529 fn test_parse_backpressure_evidence_with_ansi_codes() {
530 let payload = "\x1b[0mtests: pass\x1b[0m\n\x1b[32mlint: pass\x1b[0m\ntypecheck: pass";
531 let evidence = EventParser::parse_backpressure_evidence(payload).unwrap();
532 assert!(evidence.tests_passed);
533 assert!(evidence.lint_passed);
534 assert!(evidence.typecheck_passed);
535 assert!(evidence.all_passed());
536 }
537
538 #[test]
539 fn test_parse_review_evidence_all_pass() {
540 let payload = "tests: pass\nbuild: pass";
541 let evidence = EventParser::parse_review_evidence(payload).unwrap();
542 assert!(evidence.tests_passed);
543 assert!(evidence.build_passed);
544 assert!(evidence.is_verified());
545 }
546
547 #[test]
548 fn test_parse_review_evidence_tests_fail() {
549 let payload = "tests: fail\nbuild: pass";
550 let evidence = EventParser::parse_review_evidence(payload).unwrap();
551 assert!(!evidence.tests_passed);
552 assert!(evidence.build_passed);
553 assert!(!evidence.is_verified());
554 }
555
556 #[test]
557 fn test_parse_review_evidence_build_fail() {
558 let payload = "tests: pass\nbuild: fail";
559 let evidence = EventParser::parse_review_evidence(payload).unwrap();
560 assert!(evidence.tests_passed);
561 assert!(!evidence.build_passed);
562 assert!(!evidence.is_verified());
563 }
564
565 #[test]
566 fn test_parse_review_evidence_missing() {
567 let payload = "Looks good, approved!";
568 let evidence = EventParser::parse_review_evidence(payload);
569 assert!(evidence.is_none());
570 }
571
572 #[test]
573 fn test_parse_review_evidence_partial() {
574 let payload = "tests: pass\nLGTM";
575 let evidence = EventParser::parse_review_evidence(payload).unwrap();
576 assert!(evidence.tests_passed);
577 assert!(!evidence.build_passed);
578 assert!(!evidence.is_verified());
579 }
580
581 #[test]
582 fn test_parse_review_evidence_with_ansi_codes() {
583 let payload = "\x1b[32mtests: pass\x1b[0m\n\x1b[32mbuild: pass\x1b[0m";
584 let evidence = EventParser::parse_review_evidence(payload).unwrap();
585 assert!(evidence.tests_passed);
586 assert!(evidence.build_passed);
587 assert!(evidence.is_verified());
588 }
589
590 #[test]
591 fn test_strip_ansi_function() {
592 let payload = "\x1b[0mtests: pass\x1b[0m";
595 let evidence = EventParser::parse_backpressure_evidence(payload).unwrap();
596 assert!(evidence.tests_passed);
597
598 let payload = "\x1b[1m\x1b[32mtests: pass\x1b[0m";
600 let evidence = EventParser::parse_backpressure_evidence(payload).unwrap();
601 assert!(evidence.tests_passed);
602
603 let payload = "\x1b[31mtests: fail\x1b[0m\n\x1b[32mlint: pass\x1b[0m";
605 let evidence = EventParser::parse_backpressure_evidence(payload).unwrap();
606 assert!(!evidence.tests_passed); assert!(evidence.lint_passed);
608 }
609}