Skip to main content

ralph_core/
event_parser.rs

1//! Event parsing from CLI output.
2//!
3//! Parses XML-style event tags from agent output:
4//! ```text
5//! <event topic="impl.done">payload</event>
6//! <event topic="handoff" target="reviewer">payload</event>
7//! ```
8
9use ralph_proto::{Event, HatId};
10
11/// Strips ANSI escape sequences from a string.
12///
13/// Handles CSI sequences (\x1b[...m), OSC sequences (\x1b]...\x07),
14/// and simple escape sequences (\x1b followed by a single char).
15fn 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            // ESC character - start of escape sequence
23            i += 1;
24            if i >= bytes.len() {
25                break;
26            }
27
28            match bytes[i] {
29                b'[' => {
30                    // CSI sequence: ESC [ ... (final byte in 0x40-0x7E range)
31                    i += 1;
32                    while i < bytes.len() && !(0x40..=0x7E).contains(&bytes[i]) {
33                        i += 1;
34                    }
35                    if i < bytes.len() {
36                        i += 1; // Skip final byte
37                    }
38                }
39                b']' => {
40                    // OSC sequence: ESC ] ... (terminated by BEL or ST)
41                    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                    // Simple escape sequence: ESC + single char
56                    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/// Evidence of backpressure checks for build.done events.
69#[derive(Debug, Clone, PartialEq)]
70pub struct BackpressureEvidence {
71    pub tests_passed: bool,
72    pub lint_passed: bool,
73    pub typecheck_passed: bool,
74    pub audit_passed: bool,
75    pub coverage_passed: bool,
76    pub complexity_score: Option<f64>,
77    pub duplication_passed: bool,
78    pub performance_regression: Option<bool>,
79    pub mutants: Option<MutationEvidence>,
80    /// Whether spec acceptance criteria have been verified against passing tests.
81    ///
82    /// `None` means specs evidence was not included in the payload (optional gate).
83    /// `Some(true)` means all spec criteria are satisfied.
84    /// `Some(false)` means some spec criteria are unsatisfied — blocks build.done.
85    pub specs_verified: Option<bool>,
86}
87
88impl BackpressureEvidence {
89    /// Returns true if all required checks passed.
90    ///
91    /// Mutation testing evidence is warning-only and does not affect this result.
92    /// Spec verification blocks when explicitly reported as failed (`Some(false)`),
93    /// but is optional — omitting it (`None`) does not block.
94    pub fn all_passed(&self) -> bool {
95        self.tests_passed
96            && self.lint_passed
97            && self.typecheck_passed
98            && self.audit_passed
99            && self.coverage_passed
100            && self
101                .complexity_score
102                .is_some_and(|value| value <= QualityReport::COMPLEXITY_THRESHOLD)
103            && self.duplication_passed
104            && !matches!(self.performance_regression, Some(true))
105            && !matches!(self.specs_verified, Some(false))
106    }
107}
108
109/// Status of mutation testing evidence.
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub enum MutationStatus {
112    Pass,
113    Warn,
114    Fail,
115    Unknown,
116}
117
118/// Evidence of mutation testing for build.done payloads.
119#[derive(Debug, Clone, PartialEq)]
120pub struct MutationEvidence {
121    pub status: MutationStatus,
122    pub score_percent: Option<f64>,
123}
124
125/// Evidence of verification for review.done events.
126///
127/// Enforces that review hats actually ran verification commands rather
128/// than just asserting "looks good". At minimum, tests must have been run.
129#[derive(Debug, Clone, PartialEq)]
130pub struct ReviewEvidence {
131    pub tests_passed: bool,
132    pub build_passed: bool,
133}
134
135impl ReviewEvidence {
136    /// Returns true if the review has sufficient verification.
137    ///
138    /// Both tests and build must pass to constitute a verified review.
139    pub fn is_verified(&self) -> bool {
140        self.tests_passed && self.build_passed
141    }
142}
143
144/// Structured quality report for verifier events.
145#[derive(Debug, Clone, PartialEq)]
146pub struct QualityReport {
147    pub tests_passed: Option<bool>,
148    pub lint_passed: Option<bool>,
149    pub audit_passed: Option<bool>,
150    pub coverage_percent: Option<f64>,
151    pub mutation_percent: Option<f64>,
152    pub complexity_score: Option<f64>,
153    /// Whether spec acceptance criteria are satisfied by passing tests.
154    ///
155    /// `None` means not reported (optional — does not fail thresholds).
156    /// `Some(false)` means spec criteria are unsatisfied — fails thresholds.
157    pub specs_verified: Option<bool>,
158}
159
160impl QualityReport {
161    pub const COVERAGE_THRESHOLD: f64 = 80.0;
162    pub const MUTATION_THRESHOLD: f64 = 70.0;
163    pub const COMPLEXITY_THRESHOLD: f64 = 10.0;
164
165    pub fn meets_thresholds(&self) -> bool {
166        self.tests_passed == Some(true)
167            && self.lint_passed == Some(true)
168            && self.audit_passed == Some(true)
169            && self
170                .coverage_percent
171                .is_some_and(|value| value >= Self::COVERAGE_THRESHOLD)
172            && self
173                .mutation_percent
174                .is_some_and(|value| value >= Self::MUTATION_THRESHOLD)
175            && self
176                .complexity_score
177                .is_some_and(|value| value <= Self::COMPLEXITY_THRESHOLD)
178            && !matches!(self.specs_verified, Some(false))
179    }
180
181    pub fn failed_dimensions(&self) -> Vec<&'static str> {
182        let mut failed = Vec::new();
183
184        if self.tests_passed != Some(true) {
185            failed.push("tests");
186        }
187        if self.lint_passed != Some(true) {
188            failed.push("lint");
189        }
190        if self.audit_passed != Some(true) {
191            failed.push("audit");
192        }
193        if self
194            .coverage_percent
195            .is_none_or(|value| value < Self::COVERAGE_THRESHOLD)
196        {
197            failed.push("coverage");
198        }
199        if self
200            .mutation_percent
201            .is_none_or(|value| value < Self::MUTATION_THRESHOLD)
202        {
203            failed.push("mutation");
204        }
205        if self
206            .complexity_score
207            .is_none_or(|value| value > Self::COMPLEXITY_THRESHOLD)
208        {
209            failed.push("complexity");
210        }
211        if matches!(self.specs_verified, Some(false)) {
212            failed.push("specs");
213        }
214
215        failed
216    }
217}
218
219/// Parser for extracting events from CLI output.
220#[derive(Debug, Default)]
221pub struct EventParser {
222    /// The source hat ID to attach to parsed events.
223    source: Option<HatId>,
224}
225
226impl EventParser {
227    /// Creates a new event parser.
228    pub fn new() -> Self {
229        Self::default()
230    }
231
232    /// Sets the source hat for parsed events.
233    pub fn with_source(mut self, source: impl Into<HatId>) -> Self {
234        self.source = Some(source.into());
235        self
236    }
237
238    /// Parses events from CLI output text.
239    ///
240    /// Returns a list of parsed events.
241    pub fn parse(&self, output: &str) -> Vec<Event> {
242        let mut events = Vec::new();
243        let mut remaining = output;
244
245        while let Some(start_idx) = remaining.find("<event ") {
246            let after_start = &remaining[start_idx..];
247
248            // Find the end of the opening tag
249            let Some(tag_end) = after_start.find('>') else {
250                remaining = &remaining[start_idx + 7..];
251                continue;
252            };
253
254            let opening_tag = &after_start[..tag_end + 1];
255
256            // Parse attributes from opening tag
257            let topic = Self::extract_attr(opening_tag, "topic");
258            let target = Self::extract_attr(opening_tag, "target");
259
260            let Some(topic) = topic else {
261                remaining = &remaining[start_idx + tag_end + 1..];
262                continue;
263            };
264
265            // Find the closing tag
266            let content_start = &after_start[tag_end + 1..];
267            let Some(close_idx) = content_start.find("</event>") else {
268                remaining = &remaining[start_idx + tag_end + 1..];
269                continue;
270            };
271
272            let payload = content_start[..close_idx].trim().to_string();
273
274            let mut event = Event::new(topic, payload);
275
276            if let Some(source) = &self.source {
277                event = event.with_source(source.clone());
278            }
279
280            if let Some(target) = target {
281                event = event.with_target(target);
282            }
283
284            events.push(event);
285
286            // Move past this event
287            let total_consumed = start_idx + tag_end + 1 + close_idx + 8; // 8 = "</event>".len()
288            remaining = &remaining[total_consumed..];
289        }
290
291        events
292    }
293
294    /// Extracts an attribute value from an XML-like tag.
295    fn extract_attr(tag: &str, attr: &str) -> Option<String> {
296        let pattern = format!("{attr}=\"");
297        let start = tag.find(&pattern)?;
298        let value_start = start + pattern.len();
299        let rest = &tag[value_start..];
300        let end = rest.find('"')?;
301        Some(rest[..end].to_string())
302    }
303
304    /// Parses backpressure evidence from build.done event payload.
305    ///
306    /// Expected format:
307    /// ```text
308    /// tests: pass
309    /// lint: pass
310    /// typecheck: pass
311    /// audit: pass
312    /// coverage: pass
313    /// complexity: 7           # required (<=10)
314    /// duplication: pass       # required
315    /// performance: pass       # optional (regression blocks)
316    /// mutants: pass (82%)   # optional, warning-only
317    /// specs: pass            # optional (fail blocks)
318    /// ```
319    ///
320    /// Note: ANSI escape codes are stripped before parsing to handle
321    /// colorized CLI output.
322    pub fn parse_backpressure_evidence(payload: &str) -> Option<BackpressureEvidence> {
323        // Strip ANSI codes before checking for evidence strings
324        let clean_payload = strip_ansi(payload);
325
326        let tests_passed = clean_payload.contains("tests: pass");
327        let lint_passed = clean_payload.contains("lint: pass");
328        let typecheck_passed = clean_payload.contains("typecheck: pass");
329        let audit_passed = clean_payload.contains("audit: pass");
330        let coverage_passed = clean_payload.contains("coverage: pass");
331        let complexity_score = Self::parse_complexity_evidence(&clean_payload);
332        let duplication_passed = Self::parse_duplication_evidence(&clean_payload).unwrap_or(false);
333        let performance_regression = Self::parse_performance_regression(&clean_payload);
334        let mutants = Self::parse_mutation_evidence(&clean_payload);
335        let specs_verified = Self::parse_specs_evidence(&clean_payload);
336
337        // Only return evidence if at least one check is mentioned
338        if clean_payload.contains("tests:")
339            || clean_payload.contains("lint:")
340            || clean_payload.contains("typecheck:")
341            || clean_payload.contains("audit:")
342            || clean_payload.contains("coverage:")
343            || clean_payload.contains("complexity:")
344            || clean_payload.contains("duplication:")
345            || clean_payload.contains("performance:")
346            || clean_payload.contains("perf:")
347            || clean_payload.contains("mutants:")
348            || clean_payload.contains("specs:")
349        {
350            Some(BackpressureEvidence {
351                tests_passed,
352                lint_passed,
353                typecheck_passed,
354                audit_passed,
355                coverage_passed,
356                complexity_score,
357                duplication_passed,
358                performance_regression,
359                mutants,
360                specs_verified,
361            })
362        } else {
363            None
364        }
365    }
366
367    fn parse_mutation_evidence(clean_payload: &str) -> Option<MutationEvidence> {
368        let segment = clean_payload
369            .split(|c| c == '\n' || c == ',')
370            .map(str::trim)
371            .find(|segment| segment.contains("mutants:"))?;
372
373        let normalized = segment.to_lowercase();
374        let status = if normalized.contains("mutants: pass") {
375            MutationStatus::Pass
376        } else if normalized.contains("mutants: warn") {
377            MutationStatus::Warn
378        } else if normalized.contains("mutants: fail") {
379            MutationStatus::Fail
380        } else {
381            MutationStatus::Unknown
382        };
383
384        let score_percent = Self::extract_percentage(segment);
385
386        Some(MutationEvidence {
387            status,
388            score_percent,
389        })
390    }
391
392    fn parse_complexity_evidence(clean_payload: &str) -> Option<f64> {
393        let segment = clean_payload
394            .split(|c| c == '\n' || c == ',')
395            .map(str::trim)
396            .find(|segment| segment.to_lowercase().starts_with("complexity:"))?;
397
398        Self::extract_first_number(segment)
399    }
400
401    fn parse_duplication_evidence(clean_payload: &str) -> Option<bool> {
402        let segment = clean_payload
403            .split(|c| c == '\n' || c == ',')
404            .map(str::trim)
405            .find(|segment| segment.to_lowercase().starts_with("duplication:"))?;
406
407        let normalized = segment.to_lowercase();
408        if normalized.contains("duplication: pass") {
409            Some(true)
410        } else if normalized.contains("duplication: fail") {
411            Some(false)
412        } else {
413            None
414        }
415    }
416
417    fn parse_performance_regression(clean_payload: &str) -> Option<bool> {
418        let segment = clean_payload
419            .split(|c| c == '\n' || c == ',')
420            .map(str::trim)
421            .find(|segment| {
422                let normalized = segment.to_lowercase();
423                normalized.starts_with("performance:") || normalized.starts_with("perf:")
424            })?;
425
426        let normalized = segment.to_lowercase();
427        if normalized.contains("regression") || normalized.contains("fail") {
428            Some(true)
429        } else if normalized.contains("pass")
430            || normalized.contains("ok")
431            || normalized.contains("improved")
432        {
433            Some(false)
434        } else {
435            None
436        }
437    }
438
439    /// Parses spec acceptance criteria verification evidence.
440    ///
441    /// Returns `Some(true)` for `specs: pass`, `Some(false)` for `specs: fail`,
442    /// and `None` if no specs evidence is present.
443    fn parse_specs_evidence(clean_payload: &str) -> Option<bool> {
444        let segment = clean_payload
445            .split(|c| c == '\n' || c == ',')
446            .map(str::trim)
447            .find(|segment| segment.to_lowercase().starts_with("specs:"))?;
448
449        let normalized = segment.to_lowercase();
450        if normalized.contains("specs: pass") {
451            Some(true)
452        } else if normalized.contains("specs: fail") {
453            Some(false)
454        } else {
455            None
456        }
457    }
458
459    fn extract_percentage(segment: &str) -> Option<f64> {
460        let percent_idx = segment.find('%')?;
461        let bytes = segment.as_bytes();
462        let mut start = percent_idx;
463
464        while start > 0 {
465            let prev = bytes[start - 1];
466            if prev.is_ascii_digit() || prev == b'.' {
467                start -= 1;
468            } else {
469                break;
470            }
471        }
472
473        if start == percent_idx {
474            return None;
475        }
476
477        segment[start..percent_idx].trim().parse::<f64>().ok()
478    }
479
480    fn extract_first_number(segment: &str) -> Option<f64> {
481        let bytes = segment.as_bytes();
482        let mut start = None;
483        let mut end = None;
484
485        for (idx, &byte) in bytes.iter().enumerate() {
486            if byte.is_ascii_digit() {
487                if start.is_none() {
488                    start = Some(idx);
489                }
490                end = Some(idx + 1);
491            } else if byte == b'.' && start.is_some() {
492                end = Some(idx + 1);
493            } else if start.is_some() {
494                break;
495            }
496        }
497
498        let start = start?;
499        let end = end?;
500        segment[start..end].trim().parse::<f64>().ok()
501    }
502
503    fn parse_quality_pass_fail(segment: &str) -> Option<bool> {
504        if segment.contains("pass") {
505            Some(true)
506        } else if segment.contains("fail") {
507            Some(false)
508        } else {
509            None
510        }
511    }
512
513    /// Parses review evidence from review.done event payload.
514    ///
515    /// Expected format (subset of backpressure evidence):
516    /// ```text
517    /// tests: pass
518    /// build: pass
519    /// ```
520    ///
521    /// Note: ANSI escape codes are stripped before parsing.
522    pub fn parse_review_evidence(payload: &str) -> Option<ReviewEvidence> {
523        let clean_payload = strip_ansi(payload);
524
525        let tests_passed = clean_payload.contains("tests: pass");
526        let build_passed = clean_payload.contains("build: pass");
527
528        // Only return evidence if at least one check is mentioned
529        if clean_payload.contains("tests:") || clean_payload.contains("build:") {
530            Some(ReviewEvidence {
531                tests_passed,
532                build_passed,
533            })
534        } else {
535            None
536        }
537    }
538
539    /// Parses quality report evidence from verify.* event payloads.
540    ///
541    /// Expected format:
542    /// ```text
543    /// quality.tests: pass
544    /// quality.coverage: 82%
545    /// quality.lint: pass
546    /// quality.audit: pass
547    /// quality.mutation: 71%
548    /// quality.complexity: 7
549    /// quality.specs: pass         # optional (fail blocks)
550    /// ```
551    ///
552    /// Note: ANSI escape codes are stripped before parsing.
553    pub fn parse_quality_report(payload: &str) -> Option<QualityReport> {
554        let clean_payload = strip_ansi(payload);
555        let mut report = QualityReport {
556            tests_passed: None,
557            lint_passed: None,
558            audit_passed: None,
559            coverage_percent: None,
560            mutation_percent: None,
561            complexity_score: None,
562            specs_verified: None,
563        };
564        let mut seen = false;
565
566        for segment in clean_payload
567            .split(|c| c == '\n' || c == ',')
568            .map(str::trim)
569        {
570            if segment.is_empty() {
571                continue;
572            }
573            let normalized = segment.to_lowercase();
574
575            if normalized.starts_with("quality.tests:") {
576                report.tests_passed = Self::parse_quality_pass_fail(&normalized);
577                seen = true;
578            } else if normalized.starts_with("quality.lint:") {
579                report.lint_passed = Self::parse_quality_pass_fail(&normalized);
580                seen = true;
581            } else if normalized.starts_with("quality.audit:") {
582                report.audit_passed = Self::parse_quality_pass_fail(&normalized);
583                seen = true;
584            } else if normalized.starts_with("quality.coverage:") {
585                report.coverage_percent = Self::extract_percentage(segment)
586                    .or_else(|| Self::extract_first_number(segment));
587                seen = true;
588            } else if normalized.starts_with("quality.mutation:") {
589                report.mutation_percent = Self::extract_percentage(segment)
590                    .or_else(|| Self::extract_first_number(segment));
591                seen = true;
592            } else if normalized.starts_with("quality.complexity:") {
593                report.complexity_score = Self::extract_first_number(segment);
594                seen = true;
595            } else if normalized.starts_with("quality.specs:") {
596                report.specs_verified = Self::parse_quality_pass_fail(&normalized);
597                seen = true;
598            }
599        }
600
601        if seen { Some(report) } else { None }
602    }
603
604    /// Checks if output contains the completion promise.
605    ///
606    /// Per spec: The promise must appear in the agent's final output,
607    /// not inside an `<event>` tag payload. This function:
608    /// 1. Returns false if the promise appears inside ANY event tag
609    ///    (prevents accidental completion when agents discuss the promise)
610    /// 2. Otherwise, checks that the promise is the final non-empty line
611    ///    in the stripped output (prevents prompt echo false positives)
612    pub fn contains_promise(output: &str, promise: &str) -> bool {
613        let promise = promise.trim();
614        if promise.is_empty() {
615            return false;
616        }
617
618        // Safety check: if promise appears inside any event tag, never complete
619        if Self::promise_in_event_tags(output, promise) {
620            return false;
621        }
622        let stripped = Self::strip_event_tags(output);
623
624        for line in stripped.lines().rev() {
625            let trimmed = line.trim();
626            if trimmed.is_empty() {
627                continue;
628            }
629            return trimmed == promise;
630        }
631
632        false
633    }
634
635    /// Checks if the promise appears inside any event tag payload.
636    pub fn promise_in_event_tags(output: &str, promise: &str) -> bool {
637        let mut remaining = output;
638
639        while let Some(start_idx) = remaining.find("<event ") {
640            let after_start = &remaining[start_idx..];
641
642            // Find the end of the opening tag
643            let Some(tag_end) = after_start.find('>') else {
644                remaining = &remaining[start_idx + 7..];
645                continue;
646            };
647
648            // Find the closing tag
649            let content_start = &after_start[tag_end + 1..];
650            let Some(close_idx) = content_start.find("</event>") else {
651                remaining = &remaining[start_idx + tag_end + 1..];
652                continue;
653            };
654
655            let payload = &content_start[..close_idx];
656            if payload.contains(promise) {
657                return true;
658            }
659
660            // Move past this event
661            let total_consumed = start_idx + tag_end + 1 + close_idx + 8;
662            remaining = &remaining[total_consumed..];
663        }
664
665        false
666    }
667
668    /// Strips all `<event ...>...</event>` blocks from output.
669    ///
670    /// Returns the output with event tags removed, leaving only
671    /// the "final output" text that should be checked for promises.
672    fn strip_event_tags(output: &str) -> String {
673        let mut result = String::with_capacity(output.len());
674        let mut remaining = output;
675
676        while let Some(start_idx) = remaining.find("<event ") {
677            // Add everything before this event tag
678            result.push_str(&remaining[..start_idx]);
679
680            let after_start = &remaining[start_idx..];
681
682            // Find the closing tag
683            if let Some(close_idx) = after_start.find("</event>") {
684                // Skip past the entire event block
685                remaining = &after_start[close_idx + 8..]; // 8 = "</event>".len()
686            } else {
687                // Malformed: no closing tag, keep the rest and stop
688                result.push_str(after_start);
689                remaining = "";
690                break;
691            }
692        }
693
694        // Add any remaining content after the last event
695        result.push_str(remaining);
696        result
697    }
698}
699
700#[cfg(test)]
701mod tests {
702    use super::*;
703
704    #[test]
705    fn test_parse_single_event() {
706        let output = r#"
707Some preamble text.
708<event topic="impl.done">
709Implemented the authentication module.
710</event>
711Some trailing text.
712"#;
713        let parser = EventParser::new();
714        let events = parser.parse(output);
715
716        assert_eq!(events.len(), 1);
717        assert_eq!(events[0].topic.as_str(), "impl.done");
718        assert!(events[0].payload.contains("authentication module"));
719    }
720
721    #[test]
722    fn test_parse_event_with_target() {
723        let output = r#"<event topic="handoff" target="reviewer">Please review</event>"#;
724        let parser = EventParser::new();
725        let events = parser.parse(output);
726
727        assert_eq!(events.len(), 1);
728        assert_eq!(events[0].target.as_ref().unwrap().as_str(), "reviewer");
729    }
730
731    #[test]
732    fn test_parse_multiple_events() {
733        let output = r#"
734<event topic="impl.started">Starting work</event>
735Working on implementation...
736<event topic="impl.done">Finished</event>
737"#;
738        let parser = EventParser::new();
739        let events = parser.parse(output);
740
741        assert_eq!(events.len(), 2);
742        assert_eq!(events[0].topic.as_str(), "impl.started");
743        assert_eq!(events[1].topic.as_str(), "impl.done");
744    }
745
746    #[test]
747    fn test_parse_with_source() {
748        let output = r#"<event topic="impl.done">Done</event>"#;
749        let parser = EventParser::new().with_source("implementer");
750        let events = parser.parse(output);
751
752        assert_eq!(events[0].source.as_ref().unwrap().as_str(), "implementer");
753    }
754
755    #[test]
756    fn test_no_events() {
757        let output = "Just regular output with no events.";
758        let parser = EventParser::new();
759        let events = parser.parse(output);
760
761        assert!(events.is_empty());
762    }
763
764    #[test]
765    fn test_contains_promise_requires_last_line() {
766        assert!(EventParser::contains_promise(
767            "LOOP_COMPLETE",
768            "LOOP_COMPLETE"
769        ));
770        assert!(EventParser::contains_promise(
771            "All done!\nLOOP_COMPLETE",
772            "LOOP_COMPLETE"
773        ));
774        assert!(EventParser::contains_promise(
775            "LOOP_COMPLETE   \n\n",
776            "LOOP_COMPLETE"
777        ));
778        assert!(!EventParser::contains_promise(
779            "prefix LOOP_COMPLETE suffix",
780            "LOOP_COMPLETE"
781        ));
782        assert!(!EventParser::contains_promise(
783            "LOOP_COMPLETE\nMore text",
784            "LOOP_COMPLETE"
785        ));
786        assert!(!EventParser::contains_promise("Any output", "   "));
787        assert!(!EventParser::contains_promise(
788            "No promise here",
789            "LOOP_COMPLETE"
790        ));
791    }
792
793    #[test]
794    fn test_contains_promise_ignores_event_payloads() {
795        // Promise inside event payload should NOT be detected
796        let output = r#"<event topic="build.task">Fix LOOP_COMPLETE detection</event>"#;
797        assert!(!EventParser::contains_promise(output, "LOOP_COMPLETE"));
798
799        // Promise inside event with acceptance criteria mentioning LOOP_COMPLETE
800        let output = r#"<event topic="build.task">
801## Task: Fix completion promise detection
802- Given LOOP_COMPLETE appears inside an event tag
803- Then it should be ignored
804</event>"#;
805        assert!(!EventParser::contains_promise(output, "LOOP_COMPLETE"));
806    }
807
808    #[test]
809    fn test_contains_promise_detects_outside_events() {
810        // Promise outside event tags should be detected
811        let output = r#"<event topic="build.done">Task complete</event>
812All done!
813LOOP_COMPLETE"#;
814        assert!(EventParser::contains_promise(output, "LOOP_COMPLETE"));
815
816        // Promise before event tags
817        let output = r#"LOOP_COMPLETE
818<event topic="summary">Final summary</event>"#;
819        assert!(EventParser::contains_promise(output, "LOOP_COMPLETE"));
820    }
821
822    #[test]
823    fn test_contains_promise_mixed_content() {
824        // Promise only in event payload, not in surrounding text
825        let output = r#"Working on task...
826<event topic="build.task">Fix LOOP_COMPLETE bug</event>
827Still working..."#;
828        assert!(!EventParser::contains_promise(output, "LOOP_COMPLETE"));
829
830        // Promise in both event and surrounding text - should NOT complete
831        // because promise appears inside an event tag (safety mechanism)
832        let output = r#"All tasks done. LOOP_COMPLETE
833<event topic="summary">Completed LOOP_COMPLETE task</event>"#;
834        assert!(!EventParser::contains_promise(output, "LOOP_COMPLETE"));
835    }
836
837    #[test]
838    fn test_promise_in_event_tags() {
839        // Promise inside event payload
840        let output = r#"<event topic="build.task">Fix LOOP_COMPLETE bug</event>"#;
841        assert!(EventParser::promise_in_event_tags(output, "LOOP_COMPLETE"));
842
843        // Promise not in any event
844        let output = r#"<event topic="build.done">Task complete</event>"#;
845        assert!(!EventParser::promise_in_event_tags(output, "LOOP_COMPLETE"));
846
847        // No events at all
848        let output = "Just regular text with LOOP_COMPLETE";
849        assert!(!EventParser::promise_in_event_tags(output, "LOOP_COMPLETE"));
850
851        // Multiple events, promise in second
852        let output = r#"<event topic="a">first</event>
853<event topic="b">contains LOOP_COMPLETE</event>"#;
854        assert!(EventParser::promise_in_event_tags(output, "LOOP_COMPLETE"));
855    }
856
857    #[test]
858    fn test_strip_event_tags() {
859        // Single event
860        let output = r#"before <event topic="test">payload</event> after"#;
861        let stripped = EventParser::strip_event_tags(output);
862        assert_eq!(stripped, "before  after");
863        assert!(!stripped.contains("payload"));
864
865        // Multiple events
866        let output =
867            r#"start <event topic="a">one</event> middle <event topic="b">two</event> end"#;
868        let stripped = EventParser::strip_event_tags(output);
869        assert_eq!(stripped, "start  middle  end");
870
871        // No events
872        let output = "just plain text";
873        let stripped = EventParser::strip_event_tags(output);
874        assert_eq!(stripped, "just plain text");
875    }
876
877    #[test]
878    fn test_parse_backpressure_evidence_all_pass() {
879        let payload = "tests: pass\nlint: pass\ntypecheck: pass\naudit: pass\ncoverage: pass\ncomplexity: 7\nduplication: pass\nperformance: pass";
880        let evidence = EventParser::parse_backpressure_evidence(payload).unwrap();
881        assert!(evidence.tests_passed);
882        assert!(evidence.lint_passed);
883        assert!(evidence.typecheck_passed);
884        assert!(evidence.audit_passed);
885        assert!(evidence.coverage_passed);
886        assert_eq!(evidence.complexity_score, Some(7.0));
887        assert!(evidence.duplication_passed);
888        assert_eq!(evidence.performance_regression, Some(false));
889        assert!(evidence.all_passed());
890    }
891
892    #[test]
893    fn test_parse_backpressure_evidence_some_fail() {
894        let payload = "tests: pass\nlint: fail\ntypecheck: pass\naudit: pass\ncoverage: pass\ncomplexity: 7\nduplication: pass\nperformance: pass";
895        let evidence = EventParser::parse_backpressure_evidence(payload).unwrap();
896        assert!(evidence.tests_passed);
897        assert!(!evidence.lint_passed);
898        assert!(evidence.typecheck_passed);
899        assert!(evidence.audit_passed);
900        assert!(evidence.coverage_passed);
901        assert_eq!(evidence.complexity_score, Some(7.0));
902        assert!(evidence.duplication_passed);
903        assert_eq!(evidence.performance_regression, Some(false));
904        assert!(!evidence.all_passed());
905    }
906
907    #[test]
908    fn test_parse_backpressure_evidence_missing() {
909        let payload = "Task completed successfully";
910        let evidence = EventParser::parse_backpressure_evidence(payload);
911        assert!(evidence.is_none());
912    }
913
914    #[test]
915    fn test_parse_backpressure_evidence_partial() {
916        let payload = "tests: pass\nSome other text";
917        let evidence = EventParser::parse_backpressure_evidence(payload).unwrap();
918        assert!(evidence.tests_passed);
919        assert!(!evidence.lint_passed);
920        assert!(!evidence.typecheck_passed);
921        assert!(!evidence.audit_passed);
922        assert!(!evidence.coverage_passed);
923        assert!(evidence.complexity_score.is_none());
924        assert!(!evidence.duplication_passed);
925        assert!(evidence.performance_regression.is_none());
926        assert!(!evidence.all_passed());
927    }
928
929    #[test]
930    fn test_parse_backpressure_evidence_with_ansi_codes() {
931        let payload = "\x1b[0mtests: pass\x1b[0m\n\x1b[32mlint: pass\x1b[0m\ntypecheck: pass\n\x1b[34maudit: pass\x1b[0m\n\x1b[35mcoverage: pass\x1b[0m\n\x1b[36mcomplexity: 7\x1b[0m\n\x1b[31mduplication: pass\x1b[0m\n\x1b[33mperformance: pass\x1b[0m";
932        let evidence = EventParser::parse_backpressure_evidence(payload).unwrap();
933        assert!(evidence.tests_passed);
934        assert!(evidence.lint_passed);
935        assert!(evidence.typecheck_passed);
936        assert!(evidence.audit_passed);
937        assert!(evidence.coverage_passed);
938        assert_eq!(evidence.complexity_score, Some(7.0));
939        assert!(evidence.duplication_passed);
940        assert_eq!(evidence.performance_regression, Some(false));
941        assert!(evidence.all_passed());
942    }
943
944    #[test]
945    fn test_parse_backpressure_evidence_with_mutants_pass() {
946        let payload = "tests: pass\nlint: pass\ntypecheck: pass\naudit: pass\ncoverage: pass\ncomplexity: 7\nduplication: pass\nperformance: pass\nmutants: pass (82%)";
947        let evidence = EventParser::parse_backpressure_evidence(payload).unwrap();
948        let mutants = evidence
949            .mutants
950            .as_ref()
951            .expect("mutants evidence should parse");
952        assert_eq!(mutants.status, MutationStatus::Pass);
953        assert_eq!(mutants.score_percent, Some(82.0));
954        assert_eq!(evidence.performance_regression, Some(false));
955        assert!(evidence.all_passed());
956    }
957
958    #[test]
959    fn test_parse_backpressure_evidence_with_mutants_warn() {
960        let payload = "tests: pass, lint: pass, typecheck: pass, audit: pass, coverage: pass, complexity: 7, duplication: pass, performance: pass, mutants: warn (65%)";
961        let evidence = EventParser::parse_backpressure_evidence(payload).unwrap();
962        let mutants = evidence
963            .mutants
964            .as_ref()
965            .expect("mutants evidence should parse");
966        assert_eq!(mutants.status, MutationStatus::Warn);
967        assert_eq!(mutants.score_percent, Some(65.0));
968        assert_eq!(evidence.performance_regression, Some(false));
969        assert!(evidence.all_passed());
970    }
971
972    #[test]
973    fn test_parse_backpressure_evidence_with_performance_regression() {
974        let payload = "tests: pass\nlint: pass\ntypecheck: pass\naudit: pass\ncoverage: pass\ncomplexity: 7\nduplication: pass\nperformance: regression";
975        let evidence = EventParser::parse_backpressure_evidence(payload).unwrap();
976        assert_eq!(evidence.performance_regression, Some(true));
977        assert!(!evidence.all_passed());
978    }
979
980    #[test]
981    fn test_parse_review_evidence_all_pass() {
982        let payload = "tests: pass\nbuild: pass";
983        let evidence = EventParser::parse_review_evidence(payload).unwrap();
984        assert!(evidence.tests_passed);
985        assert!(evidence.build_passed);
986        assert!(evidence.is_verified());
987    }
988
989    #[test]
990    fn test_parse_review_evidence_tests_fail() {
991        let payload = "tests: fail\nbuild: pass";
992        let evidence = EventParser::parse_review_evidence(payload).unwrap();
993        assert!(!evidence.tests_passed);
994        assert!(evidence.build_passed);
995        assert!(!evidence.is_verified());
996    }
997
998    #[test]
999    fn test_parse_review_evidence_build_fail() {
1000        let payload = "tests: pass\nbuild: fail";
1001        let evidence = EventParser::parse_review_evidence(payload).unwrap();
1002        assert!(evidence.tests_passed);
1003        assert!(!evidence.build_passed);
1004        assert!(!evidence.is_verified());
1005    }
1006
1007    #[test]
1008    fn test_parse_review_evidence_missing() {
1009        let payload = "Looks good, approved!";
1010        let evidence = EventParser::parse_review_evidence(payload);
1011        assert!(evidence.is_none());
1012    }
1013
1014    #[test]
1015    fn test_parse_review_evidence_partial() {
1016        let payload = "tests: pass\nLGTM";
1017        let evidence = EventParser::parse_review_evidence(payload).unwrap();
1018        assert!(evidence.tests_passed);
1019        assert!(!evidence.build_passed);
1020        assert!(!evidence.is_verified());
1021    }
1022
1023    #[test]
1024    fn test_parse_review_evidence_with_ansi_codes() {
1025        let payload = "\x1b[32mtests: pass\x1b[0m\n\x1b[32mbuild: pass\x1b[0m";
1026        let evidence = EventParser::parse_review_evidence(payload).unwrap();
1027        assert!(evidence.tests_passed);
1028        assert!(evidence.build_passed);
1029        assert!(evidence.is_verified());
1030    }
1031
1032    #[test]
1033    fn test_parse_quality_report_passes_thresholds() {
1034        let payload = "quality.tests: pass\nquality.coverage: 82% (>=80%)\nquality.lint: pass\nquality.audit: pass\nquality.mutation: 71% (>=70%)\nquality.complexity: 7 (<=10)";
1035        let report = EventParser::parse_quality_report(payload).unwrap();
1036        assert_eq!(report.tests_passed, Some(true));
1037        assert_eq!(report.lint_passed, Some(true));
1038        assert_eq!(report.audit_passed, Some(true));
1039        assert_eq!(report.coverage_percent, Some(82.0));
1040        assert_eq!(report.mutation_percent, Some(71.0));
1041        assert_eq!(report.complexity_score, Some(7.0));
1042        assert!(report.meets_thresholds());
1043    }
1044
1045    #[test]
1046    fn test_parse_quality_report_fails_thresholds() {
1047        let payload = "quality.tests: pass\nquality.coverage: 60%\nquality.lint: fail\nquality.audit: pass\nquality.mutation: 50%\nquality.complexity: 12";
1048        let report = EventParser::parse_quality_report(payload).unwrap();
1049        assert!(!report.meets_thresholds());
1050    }
1051
1052    #[test]
1053    fn test_parse_quality_report_missing() {
1054        let payload = "Looks good, approved!";
1055        let report = EventParser::parse_quality_report(payload);
1056        assert!(report.is_none());
1057    }
1058
1059    #[test]
1060    fn test_extract_first_number_quality_line() {
1061        let value = EventParser::extract_first_number("quality.complexity: 7 (<=10)");
1062        assert_eq!(value, Some(7.0));
1063    }
1064
1065    #[test]
1066    fn test_parse_backpressure_evidence_with_specs_pass() {
1067        let payload = "tests: pass\nlint: pass\ntypecheck: pass\naudit: pass\ncoverage: pass\ncomplexity: 7\nduplication: pass\nperformance: pass\nspecs: pass";
1068        let evidence = EventParser::parse_backpressure_evidence(payload).unwrap();
1069        assert_eq!(evidence.specs_verified, Some(true));
1070        assert!(evidence.all_passed());
1071    }
1072
1073    #[test]
1074    fn test_parse_backpressure_evidence_with_specs_fail() {
1075        let payload = "tests: pass\nlint: pass\ntypecheck: pass\naudit: pass\ncoverage: pass\ncomplexity: 7\nduplication: pass\nperformance: pass\nspecs: fail";
1076        let evidence = EventParser::parse_backpressure_evidence(payload).unwrap();
1077        assert_eq!(evidence.specs_verified, Some(false));
1078        assert!(
1079            !evidence.all_passed(),
1080            "specs: fail should block build.done"
1081        );
1082    }
1083
1084    #[test]
1085    fn test_parse_backpressure_evidence_specs_omitted_does_not_block() {
1086        // When specs evidence is not included, it should not block
1087        let payload = "tests: pass\nlint: pass\ntypecheck: pass\naudit: pass\ncoverage: pass\ncomplexity: 7\nduplication: pass\nperformance: pass";
1088        let evidence = EventParser::parse_backpressure_evidence(payload).unwrap();
1089        assert_eq!(evidence.specs_verified, None);
1090        assert!(
1091            evidence.all_passed(),
1092            "missing specs should not block build.done"
1093        );
1094    }
1095
1096    #[test]
1097    fn test_parse_backpressure_evidence_specs_comma_separated() {
1098        let payload = "tests: pass, lint: pass, typecheck: pass, audit: pass, coverage: pass, complexity: 7, duplication: pass, performance: pass, specs: pass";
1099        let evidence = EventParser::parse_backpressure_evidence(payload).unwrap();
1100        assert_eq!(evidence.specs_verified, Some(true));
1101        assert!(evidence.all_passed());
1102    }
1103
1104    #[test]
1105    fn test_parse_specs_evidence_only() {
1106        // specs: alone should be recognized as evidence
1107        let payload = "specs: pass";
1108        let evidence = EventParser::parse_backpressure_evidence(payload).unwrap();
1109        assert_eq!(evidence.specs_verified, Some(true));
1110    }
1111
1112    #[test]
1113    fn test_quality_report_with_specs_pass() {
1114        let payload = "quality.tests: pass\nquality.coverage: 82%\nquality.lint: pass\nquality.audit: pass\nquality.mutation: 71%\nquality.complexity: 7\nquality.specs: pass";
1115        let report = EventParser::parse_quality_report(payload).unwrap();
1116        assert_eq!(report.specs_verified, Some(true));
1117        assert!(report.meets_thresholds());
1118    }
1119
1120    #[test]
1121    fn test_quality_report_with_specs_fail() {
1122        let payload = "quality.tests: pass\nquality.coverage: 82%\nquality.lint: pass\nquality.audit: pass\nquality.mutation: 71%\nquality.complexity: 7\nquality.specs: fail";
1123        let report = EventParser::parse_quality_report(payload).unwrap();
1124        assert_eq!(report.specs_verified, Some(false));
1125        assert!(
1126            !report.meets_thresholds(),
1127            "specs: fail should fail quality thresholds"
1128        );
1129        assert!(report.failed_dimensions().contains(&"specs"));
1130    }
1131
1132    #[test]
1133    fn test_quality_report_specs_omitted_passes() {
1134        let payload = "quality.tests: pass\nquality.coverage: 82%\nquality.lint: pass\nquality.audit: pass\nquality.mutation: 71%\nquality.complexity: 7";
1135        let report = EventParser::parse_quality_report(payload).unwrap();
1136        assert_eq!(report.specs_verified, None);
1137        assert!(
1138            report.meets_thresholds(),
1139            "missing specs should not fail quality thresholds"
1140        );
1141        assert!(!report.failed_dimensions().contains(&"specs"));
1142    }
1143
1144    #[test]
1145    fn test_strip_ansi_function() {
1146        // Test the internal strip_ansi function via parse_backpressure_evidence
1147        // Simple CSI reset sequence
1148        let payload = "\x1b[0mtests: pass\x1b[0m";
1149        let evidence = EventParser::parse_backpressure_evidence(payload).unwrap();
1150        assert!(evidence.tests_passed);
1151
1152        // Bold green text
1153        let payload = "\x1b[1m\x1b[32mtests: pass\x1b[0m";
1154        let evidence = EventParser::parse_backpressure_evidence(payload).unwrap();
1155        assert!(evidence.tests_passed);
1156
1157        // Multiple sequences mixed with content
1158        let payload = "\x1b[31mtests: fail\x1b[0m\n\x1b[32mlint: pass\x1b[0m";
1159        let evidence = EventParser::parse_backpressure_evidence(payload).unwrap();
1160        assert!(!evidence.tests_passed); // "tests: fail" not "tests: pass"
1161        assert!(evidence.lint_passed);
1162        assert!(!evidence.coverage_passed);
1163    }
1164}