Skip to main content

coda_core/
parser.rs

1//! Response parsing utilities for agent output.
2//!
3//! Pure functions for extracting structured data (YAML blocks, PR URLs,
4//! review issues, verification results) from agent text responses.
5//! These are decoupled from the [`Runner`](crate::runner::Runner) to
6//! keep parsing logic independently testable.
7
8/// Extracts a YAML code block from a response string.
9///
10/// Tries the following strategies in order:
11/// 1. Fenced `` ```yaml ... ``` `` block
12/// 2. Fenced `` ``` ... ``` `` block whose content starts with `issues:` or `result:`
13/// 3. Returns `None` if no recognizable block is found
14///
15/// # Examples
16///
17/// ```
18/// use coda_core::parser::extract_yaml_block;
19///
20/// let text = "Some text\n```yaml\nissues: []\n```\nMore text";
21/// assert_eq!(extract_yaml_block(text), Some("issues: []".to_string()));
22/// ```
23pub fn extract_yaml_block(text: &str) -> Option<String> {
24    // Strategy 1: ```yaml ... ``` block
25    if let Some(start) = text.find("```yaml") {
26        let content_start = start + "```yaml".len();
27        if let Some(end) = text[content_start..].find("```") {
28            return Some(text[content_start..content_start + end].trim().to_string());
29        }
30    }
31
32    // Strategy 2: unmarked ``` ... ``` block that looks like YAML
33    if let Some(start) = text.find("```\n") {
34        let content_start = start + "```\n".len();
35        if let Some(end) = text[content_start..].find("```") {
36            let content = text[content_start..content_start + end].trim();
37            if content.starts_with("issues:") || content.starts_with("result:") {
38                return Some(content.to_string());
39            }
40        }
41    }
42
43    None
44}
45
46/// Parses review issues from the agent's YAML response.
47///
48/// Returns a list of issue descriptions for critical/major issues only.
49/// Minor and informational severity issues are filtered out.
50///
51/// # Examples
52///
53/// ```
54/// use coda_core::parser::parse_review_issues;
55///
56/// let response = "```yaml\nissues:\n  - severity: critical\n    file: src/main.rs\n    description: unwrap used\n    suggestion: use ? operator\n```";
57/// let issues = parse_review_issues(response);
58/// assert_eq!(issues.len(), 1);
59/// ```
60pub fn parse_review_issues(response: &str) -> Vec<String> {
61    let yaml_content = extract_yaml_block(response);
62
63    if let Some(yaml) = yaml_content
64        && let Ok(parsed) = serde_yaml::from_str::<serde_json::Value>(&yaml)
65        && let Some(issues) = parsed.get("issues").and_then(|v| v.as_array())
66    {
67        return issues
68            .iter()
69            .filter_map(|issue| {
70                let severity = issue.get("severity")?.as_str()?;
71                if severity == "critical" || severity == "major" {
72                    let desc = issue
73                        .get("description")
74                        .and_then(|d| d.as_str())
75                        .unwrap_or("Unknown issue");
76                    let file = issue
77                        .get("file")
78                        .and_then(|f| f.as_str())
79                        .unwrap_or("unknown");
80                    let suggestion = issue
81                        .get("suggestion")
82                        .and_then(|s| s.as_str())
83                        .unwrap_or("");
84                    Some(format!(
85                        "[{severity}] {file}: {desc}. Suggestion: {suggestion}"
86                    ))
87                } else {
88                    None
89                }
90            })
91            .collect();
92    }
93
94    Vec::new()
95}
96
97/// Parses verification results from the agent's YAML response.
98///
99/// Returns `(passed_count, failed_details)`. If the response cannot be
100/// parsed, returns `(0, ["Unable to parse..."])` to avoid false positives.
101///
102/// # Examples
103///
104/// ```
105/// use coda_core::parser::parse_verification_result;
106///
107/// let response = "```yaml\nresult: passed\ntotal_count: 5\n```";
108/// let (passed, failed) = parse_verification_result(response);
109/// assert_eq!(passed, 5);
110/// assert!(failed.is_empty());
111/// ```
112pub fn parse_verification_result(response: &str) -> (u32, Vec<String>) {
113    let yaml_content = extract_yaml_block(response);
114
115    if let Some(yaml) = yaml_content
116        && let Ok(parsed) = serde_yaml::from_str::<serde_json::Value>(&yaml)
117    {
118        let result = parsed
119            .get("result")
120            .and_then(|v| v.as_str())
121            .unwrap_or("unknown");
122
123        if result == "passed" {
124            let total = parsed
125                .get("total_count")
126                .and_then(|v| v.as_u64())
127                .unwrap_or(0) as u32;
128            return (total, Vec::new());
129        }
130
131        let mut failed = Vec::new();
132        if let Some(checks) = parsed.get("checks").and_then(|v| v.as_array()) {
133            let passed = checks
134                .iter()
135                .filter(|c| c.get("status").and_then(|s| s.as_str()) == Some("passed"))
136                .count() as u32;
137
138            for check in checks {
139                if check.get("status").and_then(|s| s.as_str()) == Some("failed") {
140                    let name = check
141                        .get("name")
142                        .and_then(|n| n.as_str())
143                        .unwrap_or("unknown");
144                    let details = check
145                        .get("details")
146                        .and_then(|d| d.as_str())
147                        .unwrap_or("no details");
148                    failed.push(format!("{name}: {details}"));
149                }
150            }
151
152            return (passed, failed);
153        }
154    }
155
156    // If we can't parse, treat as a failure to avoid false positives.
157    // The verification loop will ask the agent to fix / re-run, or exhaust retries.
158    (
159        0,
160        vec![
161            "Unable to parse verification result from agent response. Manual review required."
162                .to_string(),
163        ],
164    )
165}
166
167/// Extracts a GitHub PR URL from text.
168///
169/// Scans each line for `https://github.com/.../pull/N` patterns.
170///
171/// # Examples
172///
173/// ```
174/// use coda_core::parser::extract_pr_url;
175///
176/// let text = "PR created: https://github.com/org/repo/pull/42";
177/// assert_eq!(
178///     extract_pr_url(text),
179///     Some("https://github.com/org/repo/pull/42".to_string()),
180/// );
181/// ```
182pub fn extract_pr_url(text: &str) -> Option<String> {
183    for line in text.lines() {
184        if let Some(start) = line.find("https://github.com/") {
185            let url_part = &line[start..];
186            // Find end of URL (whitespace, quote, paren, or end of line)
187            let end = url_part
188                .find(|c: char| c.is_whitespace() || c == '"' || c == '\'' || c == ')')
189                .unwrap_or(url_part.len());
190            let url = &url_part[..end];
191            // Must contain /pull/ AND end with a numeric PR number.
192            // Rejects /pull/new/<branch> (GitHub's "create PR" page URL
193            // that appears in `git push` output).
194            if url.contains("/pull/") && extract_pr_number(url).is_some_and(|n| n > 0) {
195                return Some(url.to_string());
196            }
197        }
198    }
199    None
200}
201
202/// Extracts the PR number from a GitHub PR URL.
203///
204/// # Examples
205///
206/// ```
207/// use coda_core::parser::extract_pr_number;
208///
209/// assert_eq!(
210///     extract_pr_number("https://github.com/org/repo/pull/42"),
211///     Some(42),
212/// );
213/// ```
214pub fn extract_pr_number(url: &str) -> Option<u32> {
215    url.rsplit('/').next()?.parse().ok()
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    // ── extract_yaml_block ──────────────────────────────────────────
223
224    #[test]
225    fn test_should_find_yaml_block() {
226        let text = "Some text\n```yaml\nissues: []\n```\nMore text";
227        let yaml = extract_yaml_block(text);
228        assert_eq!(yaml, Some("issues: []".to_string()));
229    }
230
231    #[test]
232    fn test_should_return_none_for_no_yaml_block() {
233        let text = "This is plain text with no code blocks at all.";
234        let yaml = extract_yaml_block(text);
235        assert!(yaml.is_none());
236    }
237
238    #[test]
239    fn test_should_extract_first_yaml_block_from_multiple() {
240        let text = r#"
241First block:
242```yaml
243issues:
244  - severity: "critical"
245    description: "First issue"
246```
247
248Second block:
249```yaml
250result: "passed"
251```
252"#;
253
254        let yaml = extract_yaml_block(text);
255        assert!(yaml.is_some());
256        let content = yaml.unwrap();
257        assert!(content.contains("issues:"));
258    }
259
260    #[test]
261    fn test_should_extract_yaml_from_unmarked_code_block() {
262        let text = "Here are results:\n```\nissues:\n  - severity: critical\n```\nEnd.";
263        let yaml = extract_yaml_block(text);
264        assert!(yaml.is_some());
265        assert!(yaml.unwrap().contains("issues:"));
266    }
267
268    #[test]
269    fn test_should_return_none_for_text_without_code_blocks() {
270        let text = "result: passed\ntotal_count: 3";
271        let yaml = extract_yaml_block(text);
272        // No code block → None (overly permissive fallback removed)
273        assert!(yaml.is_none());
274    }
275
276    // ── parse_review_issues ─────────────────────────────────────────
277
278    #[test]
279    fn test_should_parse_review_issues_from_yaml() {
280        let response = r#"
281Here are the review findings:
282
283```yaml
284issues:
285  - severity: "critical"
286    file: "src/main.rs"
287    line: 42
288    description: "Use of unwrap in production code"
289    suggestion: "Replace with ? operator"
290  - severity: "minor"
291    file: "src/lib.rs"
292    line: 10
293    description: "Missing doc comment"
294    suggestion: "Add /// documentation"
295```
296"#;
297
298        let issues = parse_review_issues(response);
299        assert_eq!(issues.len(), 1); // Only critical, not minor
300        assert!(issues[0].contains("unwrap"));
301    }
302
303    #[test]
304    fn test_should_return_empty_for_no_issues() {
305        let response = r#"
306```yaml
307issues: []
308```
309"#;
310
311        let issues = parse_review_issues(response);
312        assert!(issues.is_empty());
313    }
314
315    #[test]
316    fn test_should_parse_review_major_issues() {
317        let response = r#"
318```yaml
319issues:
320  - severity: "major"
321    file: "src/db.rs"
322    line: 100
323    description: "SQL injection vulnerability"
324    suggestion: "Use parameterized queries"
325  - severity: "major"
326    file: "src/api.rs"
327    line: 55
328    description: "Missing authentication check"
329    suggestion: "Add auth middleware"
330```
331"#;
332
333        let issues = parse_review_issues(response);
334        assert_eq!(issues.len(), 2);
335        assert!(issues[0].contains("SQL injection"));
336        assert!(issues[1].contains("authentication"));
337    }
338
339    #[test]
340    fn test_should_parse_review_with_no_yaml_structure() {
341        let text = "The code looks good! No issues found.";
342        let issues = parse_review_issues(text);
343        assert!(issues.is_empty());
344    }
345
346    // ── parse_verification_result ───────────────────────────────────
347
348    #[test]
349    fn test_should_parse_verification_passed() {
350        let response = r#"
351```yaml
352result: "passed"
353total_count: 5
354checks:
355  - name: "cargo build"
356    status: "passed"
357```
358"#;
359
360        let (passed, failed) = parse_verification_result(response);
361        assert_eq!(passed, 5);
362        assert!(failed.is_empty());
363    }
364
365    #[test]
366    fn test_should_parse_verification_failed() {
367        let response = r#"
368```yaml
369result: "failed"
370checks:
371  - name: "cargo build"
372    status: "passed"
373  - name: "cargo test"
374    status: "failed"
375    details: "2 tests failed"
376failed_count: 1
377total_count: 2
378```
379"#;
380
381        let (passed, failed) = parse_verification_result(response);
382        assert_eq!(passed, 1);
383        assert_eq!(failed.len(), 1);
384        assert!(failed[0].contains("cargo test"));
385    }
386
387    #[test]
388    fn test_should_treat_unparsable_verification_as_failure() {
389        let text = "All tests passed successfully!";
390        let (passed, failed) = parse_verification_result(text);
391        assert_eq!(passed, 0);
392        assert_eq!(failed.len(), 1);
393        assert!(failed[0].contains("Unable to parse"));
394    }
395
396    // ── extract_pr_url ──────────────────────────────────────────────
397
398    #[test]
399    fn test_should_extract_pr_url() {
400        let text = "PR created: https://github.com/org/repo/pull/42\nDone!";
401        assert_eq!(
402            extract_pr_url(text),
403            Some("https://github.com/org/repo/pull/42".to_string())
404        );
405    }
406
407    #[test]
408    fn test_should_return_none_when_no_pr_url_found() {
409        let text = "No PR URL here, just some text.";
410        assert!(extract_pr_url(text).is_none());
411    }
412
413    #[test]
414    fn test_should_extract_pr_url_from_markdown_link() {
415        let text = "Created [PR #42](https://github.com/org/repo/pull/42) for review.";
416        let url = extract_pr_url(text);
417        assert_eq!(url, Some("https://github.com/org/repo/pull/42".to_string()));
418    }
419
420    #[test]
421    fn test_should_not_extract_non_pr_github_url() {
422        let text = "See https://github.com/org/repo/issues/10 for details.";
423        assert!(extract_pr_url(text).is_none());
424    }
425
426    #[test]
427    fn test_should_reject_git_push_create_pr_page_url() {
428        // `git push` output includes a "Create a pull request" link that
429        // points to /pull/new/<branch>, NOT an actual PR.
430        let text = "remote: Create a pull request for 'feature/update-doc' on GitHub by visiting:\nremote:   https://github.com/lehuagavin/coda/pull/new/feature/update-doc";
431        assert!(extract_pr_url(text).is_none());
432    }
433
434    // ── extract_pr_number ───────────────────────────────────────────
435
436    #[test]
437    fn test_should_extract_pr_number() {
438        assert_eq!(
439            extract_pr_number("https://github.com/org/repo/pull/42"),
440            Some(42)
441        );
442    }
443
444    #[test]
445    fn test_should_extract_pr_number_from_valid_url() {
446        assert_eq!(
447            extract_pr_number("https://github.com/org/repo/pull/123"),
448            Some(123)
449        );
450        assert_eq!(
451            extract_pr_number("https://github.com/my-org/my-repo/pull/1"),
452            Some(1)
453        );
454    }
455
456    #[test]
457    fn test_should_return_none_for_non_numeric_pr_number() {
458        assert_eq!(extract_pr_number("https://github.com/org/repo/pull/"), None);
459        assert_eq!(
460            extract_pr_number("https://github.com/org/repo/pull/abc"),
461            None
462        );
463    }
464}