Skip to main content

car_builder/
parse.rs

1//! Model output → [`Workflow`] parser with JSON repair.
2//!
3//! Mirrors `car-active-planner`'s proposal parser: try a direct parse, then
4//! extract the first `{...}` block (handles markdown fences / preamble), then
5//! repair common JSON issues (currently: trailing commas).
6
7use car_ir::json_extract::{extract_json_object, repair_json};
8use car_workflow::Workflow;
9
10/// Parse model text into a [`Workflow`], tolerating fenced/preambled output.
11pub fn parse_workflow(text: &str) -> Result<Workflow, String> {
12    let trimmed = text.trim();
13
14    // 1. Direct parse.
15    if let Ok(wf) = serde_json::from_str::<Workflow>(trimmed) {
16        return Ok(wf);
17    }
18
19    // 2. Extract the first JSON object, then 3. repair it.
20    if let Some(json) = extract_json_object(trimmed) {
21        if let Ok(wf) = serde_json::from_str::<Workflow>(&json) {
22            return Ok(wf);
23        }
24        let repaired = repair_json(&json);
25        // Surface serde's error (e.g. "missing field `system_prompt`") so the
26        // repair prompt can name the exact defect instead of a generic message.
27        return serde_json::from_str::<Workflow>(&repaired).map_err(|e| {
28            format!("extracted a JSON object but it is not a valid workflow: {e}")
29        });
30    }
31
32    Err(format!(
33        "no JSON workflow object found in output ({} chars)",
34        text.len()
35    ))
36}
37
38#[cfg(test)]
39mod tests {
40    use super::*;
41
42    const MINIMAL: &str = r#"{
43        "id": "wf1",
44        "name": "Test",
45        "start": "a",
46        "stages": [
47            {"id": "a", "name": "A", "step": {"type": "approval", "prompt": "ok?", "fields": [], "output_key": "decision"}}
48        ],
49        "edges": []
50    }"#;
51
52    #[test]
53    fn parses_clean_json() {
54        let wf = parse_workflow(MINIMAL).unwrap();
55        assert_eq!(wf.id, "wf1");
56        assert_eq!(wf.start, "a");
57        assert_eq!(wf.stages.len(), 1);
58    }
59
60    #[test]
61    fn parses_markdown_fenced() {
62        let text = format!("Here is the workflow:\n\n```json\n{MINIMAL}\n```\n\nDone.");
63        let wf = parse_workflow(&text).unwrap();
64        assert_eq!(wf.id, "wf1");
65    }
66
67    #[test]
68    fn parses_with_preamble() {
69        let text = format!("Sure! {MINIMAL}");
70        let wf = parse_workflow(&text).unwrap();
71        assert_eq!(wf.id, "wf1");
72    }
73
74    #[test]
75    fn garbage_fails() {
76        assert!(parse_workflow("not json at all").is_err());
77    }
78
79    #[test]
80    fn json_object_that_isnt_a_workflow_fails() {
81        let err = parse_workflow(r#"{"hello": "world"}"#).unwrap_err();
82        assert!(err.contains("not a valid workflow"));
83    }
84}