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, Default)]
85pub struct EventParser {
86 source: Option<HatId>,
88}
89
90impl EventParser {
91 pub fn new() -> Self {
93 Self::default()
94 }
95
96 pub fn with_source(mut self, source: impl Into<HatId>) -> Self {
98 self.source = Some(source.into());
99 self
100 }
101
102 pub fn parse(&self, output: &str) -> Vec<Event> {
106 let mut events = Vec::new();
107 let mut remaining = output;
108
109 while let Some(start_idx) = remaining.find("<event ") {
110 let after_start = &remaining[start_idx..];
111
112 let Some(tag_end) = after_start.find('>') else {
114 remaining = &remaining[start_idx + 7..];
115 continue;
116 };
117
118 let opening_tag = &after_start[..tag_end + 1];
119
120 let topic = Self::extract_attr(opening_tag, "topic");
122 let target = Self::extract_attr(opening_tag, "target");
123
124 let Some(topic) = topic else {
125 remaining = &remaining[start_idx + tag_end + 1..];
126 continue;
127 };
128
129 let content_start = &after_start[tag_end + 1..];
131 let Some(close_idx) = content_start.find("</event>") else {
132 remaining = &remaining[start_idx + tag_end + 1..];
133 continue;
134 };
135
136 let payload = content_start[..close_idx].trim().to_string();
137
138 let mut event = Event::new(topic, payload);
139
140 if let Some(source) = &self.source {
141 event = event.with_source(source.clone());
142 }
143
144 if let Some(target) = target {
145 event = event.with_target(target);
146 }
147
148 events.push(event);
149
150 let total_consumed = start_idx + tag_end + 1 + close_idx + 8; remaining = &remaining[total_consumed..];
153 }
154
155 events
156 }
157
158 fn extract_attr(tag: &str, attr: &str) -> Option<String> {
160 let pattern = format!("{attr}=\"");
161 let start = tag.find(&pattern)?;
162 let value_start = start + pattern.len();
163 let rest = &tag[value_start..];
164 let end = rest.find('"')?;
165 Some(rest[..end].to_string())
166 }
167
168 pub fn parse_backpressure_evidence(payload: &str) -> Option<BackpressureEvidence> {
180 let clean_payload = strip_ansi(payload);
182
183 let tests_passed = clean_payload.contains("tests: pass");
184 let lint_passed = clean_payload.contains("lint: pass");
185 let typecheck_passed = clean_payload.contains("typecheck: pass");
186
187 if clean_payload.contains("tests:")
189 || clean_payload.contains("lint:")
190 || clean_payload.contains("typecheck:")
191 {
192 Some(BackpressureEvidence {
193 tests_passed,
194 lint_passed,
195 typecheck_passed,
196 })
197 } else {
198 None
199 }
200 }
201
202 pub fn contains_promise(output: &str, promise: &str) -> bool {
210 if Self::promise_in_event_tags(output, promise) {
212 return false;
213 }
214 let stripped = Self::strip_event_tags(output);
215 stripped.contains(promise)
216 }
217
218 pub fn promise_in_event_tags(output: &str, promise: &str) -> bool {
220 let mut remaining = output;
221
222 while let Some(start_idx) = remaining.find("<event ") {
223 let after_start = &remaining[start_idx..];
224
225 let Some(tag_end) = after_start.find('>') else {
227 remaining = &remaining[start_idx + 7..];
228 continue;
229 };
230
231 let content_start = &after_start[tag_end + 1..];
233 let Some(close_idx) = content_start.find("</event>") else {
234 remaining = &remaining[start_idx + tag_end + 1..];
235 continue;
236 };
237
238 let payload = &content_start[..close_idx];
239 if payload.contains(promise) {
240 return true;
241 }
242
243 let total_consumed = start_idx + tag_end + 1 + close_idx + 8;
245 remaining = &remaining[total_consumed..];
246 }
247
248 false
249 }
250
251 fn strip_event_tags(output: &str) -> String {
256 let mut result = String::with_capacity(output.len());
257 let mut remaining = output;
258
259 while let Some(start_idx) = remaining.find("<event ") {
260 result.push_str(&remaining[..start_idx]);
262
263 let after_start = &remaining[start_idx..];
264
265 if let Some(close_idx) = after_start.find("</event>") {
267 remaining = &after_start[close_idx + 8..]; } else {
270 result.push_str(after_start);
272 remaining = "";
273 break;
274 }
275 }
276
277 result.push_str(remaining);
279 result
280 }
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286
287 #[test]
288 fn test_parse_single_event() {
289 let output = r#"
290Some preamble text.
291<event topic="impl.done">
292Implemented the authentication module.
293</event>
294Some trailing text.
295"#;
296 let parser = EventParser::new();
297 let events = parser.parse(output);
298
299 assert_eq!(events.len(), 1);
300 assert_eq!(events[0].topic.as_str(), "impl.done");
301 assert!(events[0].payload.contains("authentication module"));
302 }
303
304 #[test]
305 fn test_parse_event_with_target() {
306 let output = r#"<event topic="handoff" target="reviewer">Please review</event>"#;
307 let parser = EventParser::new();
308 let events = parser.parse(output);
309
310 assert_eq!(events.len(), 1);
311 assert_eq!(events[0].target.as_ref().unwrap().as_str(), "reviewer");
312 }
313
314 #[test]
315 fn test_parse_multiple_events() {
316 let output = r#"
317<event topic="impl.started">Starting work</event>
318Working on implementation...
319<event topic="impl.done">Finished</event>
320"#;
321 let parser = EventParser::new();
322 let events = parser.parse(output);
323
324 assert_eq!(events.len(), 2);
325 assert_eq!(events[0].topic.as_str(), "impl.started");
326 assert_eq!(events[1].topic.as_str(), "impl.done");
327 }
328
329 #[test]
330 fn test_parse_with_source() {
331 let output = r#"<event topic="impl.done">Done</event>"#;
332 let parser = EventParser::new().with_source("implementer");
333 let events = parser.parse(output);
334
335 assert_eq!(events[0].source.as_ref().unwrap().as_str(), "implementer");
336 }
337
338 #[test]
339 fn test_no_events() {
340 let output = "Just regular output with no events.";
341 let parser = EventParser::new();
342 let events = parser.parse(output);
343
344 assert!(events.is_empty());
345 }
346
347 #[test]
348 fn test_contains_promise() {
349 assert!(EventParser::contains_promise(
350 "LOOP_COMPLETE",
351 "LOOP_COMPLETE"
352 ));
353 assert!(EventParser::contains_promise(
354 "prefix LOOP_COMPLETE suffix",
355 "LOOP_COMPLETE"
356 ));
357 assert!(!EventParser::contains_promise(
358 "No promise here",
359 "LOOP_COMPLETE"
360 ));
361 }
362
363 #[test]
364 fn test_contains_promise_ignores_event_payloads() {
365 let output = r#"<event topic="build.task">Fix LOOP_COMPLETE detection</event>"#;
367 assert!(!EventParser::contains_promise(output, "LOOP_COMPLETE"));
368
369 let output = r#"<event topic="build.task">
371## Task: Fix completion promise detection
372- Given LOOP_COMPLETE appears inside an event tag
373- Then it should be ignored
374</event>"#;
375 assert!(!EventParser::contains_promise(output, "LOOP_COMPLETE"));
376 }
377
378 #[test]
379 fn test_contains_promise_detects_outside_events() {
380 let output = r#"<event topic="build.done">Task complete</event>
382All done! LOOP_COMPLETE"#;
383 assert!(EventParser::contains_promise(output, "LOOP_COMPLETE"));
384
385 let output = r#"LOOP_COMPLETE
387<event topic="summary">Final summary</event>"#;
388 assert!(EventParser::contains_promise(output, "LOOP_COMPLETE"));
389 }
390
391 #[test]
392 fn test_contains_promise_mixed_content() {
393 let output = r#"Working on task...
395<event topic="build.task">Fix LOOP_COMPLETE bug</event>
396Still working..."#;
397 assert!(!EventParser::contains_promise(output, "LOOP_COMPLETE"));
398
399 let output = r#"All tasks done. LOOP_COMPLETE
402<event topic="summary">Completed LOOP_COMPLETE task</event>"#;
403 assert!(!EventParser::contains_promise(output, "LOOP_COMPLETE"));
404 }
405
406 #[test]
407 fn test_promise_in_event_tags() {
408 let output = r#"<event topic="build.task">Fix LOOP_COMPLETE bug</event>"#;
410 assert!(EventParser::promise_in_event_tags(output, "LOOP_COMPLETE"));
411
412 let output = r#"<event topic="build.done">Task complete</event>"#;
414 assert!(!EventParser::promise_in_event_tags(output, "LOOP_COMPLETE"));
415
416 let output = "Just regular text with LOOP_COMPLETE";
418 assert!(!EventParser::promise_in_event_tags(output, "LOOP_COMPLETE"));
419
420 let output = r#"<event topic="a">first</event>
422<event topic="b">contains LOOP_COMPLETE</event>"#;
423 assert!(EventParser::promise_in_event_tags(output, "LOOP_COMPLETE"));
424 }
425
426 #[test]
427 fn test_strip_event_tags() {
428 let output = r#"before <event topic="test">payload</event> after"#;
430 let stripped = EventParser::strip_event_tags(output);
431 assert_eq!(stripped, "before after");
432 assert!(!stripped.contains("payload"));
433
434 let output =
436 r#"start <event topic="a">one</event> middle <event topic="b">two</event> end"#;
437 let stripped = EventParser::strip_event_tags(output);
438 assert_eq!(stripped, "start middle end");
439
440 let output = "just plain text";
442 let stripped = EventParser::strip_event_tags(output);
443 assert_eq!(stripped, "just plain text");
444 }
445
446 #[test]
447 fn test_parse_backpressure_evidence_all_pass() {
448 let payload = "tests: pass\nlint: pass\ntypecheck: pass";
449 let evidence = EventParser::parse_backpressure_evidence(payload).unwrap();
450 assert!(evidence.tests_passed);
451 assert!(evidence.lint_passed);
452 assert!(evidence.typecheck_passed);
453 assert!(evidence.all_passed());
454 }
455
456 #[test]
457 fn test_parse_backpressure_evidence_some_fail() {
458 let payload = "tests: pass\nlint: fail\ntypecheck: pass";
459 let evidence = EventParser::parse_backpressure_evidence(payload).unwrap();
460 assert!(evidence.tests_passed);
461 assert!(!evidence.lint_passed);
462 assert!(evidence.typecheck_passed);
463 assert!(!evidence.all_passed());
464 }
465
466 #[test]
467 fn test_parse_backpressure_evidence_missing() {
468 let payload = "Task completed successfully";
469 let evidence = EventParser::parse_backpressure_evidence(payload);
470 assert!(evidence.is_none());
471 }
472
473 #[test]
474 fn test_parse_backpressure_evidence_partial() {
475 let payload = "tests: pass\nSome other text";
476 let evidence = EventParser::parse_backpressure_evidence(payload).unwrap();
477 assert!(evidence.tests_passed);
478 assert!(!evidence.lint_passed);
479 assert!(!evidence.typecheck_passed);
480 assert!(!evidence.all_passed());
481 }
482
483 #[test]
484 fn test_parse_backpressure_evidence_with_ansi_codes() {
485 let payload = "\x1b[0mtests: pass\x1b[0m\n\x1b[32mlint: pass\x1b[0m\ntypecheck: pass";
486 let evidence = EventParser::parse_backpressure_evidence(payload).unwrap();
487 assert!(evidence.tests_passed);
488 assert!(evidence.lint_passed);
489 assert!(evidence.typecheck_passed);
490 assert!(evidence.all_passed());
491 }
492
493 #[test]
494 fn test_strip_ansi_function() {
495 let payload = "\x1b[0mtests: pass\x1b[0m";
498 let evidence = EventParser::parse_backpressure_evidence(payload).unwrap();
499 assert!(evidence.tests_passed);
500
501 let payload = "\x1b[1m\x1b[32mtests: pass\x1b[0m";
503 let evidence = EventParser::parse_backpressure_evidence(payload).unwrap();
504 assert!(evidence.tests_passed);
505
506 let payload = "\x1b[31mtests: fail\x1b[0m\n\x1b[32mlint: pass\x1b[0m";
508 let evidence = EventParser::parse_backpressure_evidence(payload).unwrap();
509 assert!(!evidence.tests_passed); assert!(evidence.lint_passed);
511 }
512}