use car_ir::ActionProposal;
use car_ir::json_extract::{extract_json_object as extract_json_block, repair_json};
#[derive(Debug, Clone)]
pub struct ParseError {
pub message: String,
pub raw_text: String,
}
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.message)
}
}
pub fn parse_proposal(text: &str) -> Result<ActionProposal, ParseError> {
let trimmed = text.trim();
if let Ok(proposal) = serde_json::from_str::<ActionProposal>(trimmed) {
return Ok(proposal);
}
if let Some(json_str) = extract_json_block(trimmed) {
if let Ok(proposal) = serde_json::from_str::<ActionProposal>(&json_str) {
return Ok(proposal);
}
let repaired = repair_json(&json_str);
if let Ok(proposal) = serde_json::from_str::<ActionProposal>(&repaired) {
return Ok(proposal);
}
}
Err(ParseError {
message: format!("failed to parse ActionProposal from LLM output ({} chars)", text.len()),
raw_text: text.to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_clean_json() {
let json = r#"{"id":"p1","source":"test","actions":[{"id":"a1","type":"tool_call","tool":"search","parameters":{"q":"rust"},"preconditions":[],"expected_effects":{},"state_dependencies":[],"idempotent":false,"max_retries":3,"failure_behavior":"abort","timeout_ms":null,"metadata":{}}],"context":{}}"#;
let result = parse_proposal(json);
assert!(result.is_ok());
let p = result.unwrap();
assert_eq!(p.id, "p1");
assert_eq!(p.actions.len(), 1);
}
#[test]
fn parse_markdown_fenced() {
let text = r#"Here is the plan:
```json
{"id":"p2","source":"test","actions":[],"context":{}}
```
This should work."#;
let result = parse_proposal(text);
assert!(result.is_ok());
assert_eq!(result.unwrap().id, "p2");
}
#[test]
fn parse_with_preamble() {
let text = r#"Sure, here's your plan:
{"id":"p3","source":"test","actions":[],"context":{}}
Let me know if you need changes."#;
let result = parse_proposal(text);
assert!(result.is_ok());
assert_eq!(result.unwrap().id, "p3");
}
#[test]
fn parse_trailing_comma_repair() {
let text = r#"{"id":"p4","source":"test","actions":[],"context":{},}"#;
let result = parse_proposal(text);
assert!(result.is_ok());
}
#[test]
fn parse_garbage_fails() {
let result = parse_proposal("not json at all");
assert!(result.is_err());
assert!(result.unwrap_err().message.contains("failed to parse"));
}
#[test]
fn extract_nested_json() {
let text = r#"blah {"id":"x","inner":{"a":1}} blah"#;
let block = extract_json_block(text).unwrap();
assert_eq!(block, r#"{"id":"x","inner":{"a":1}}"#);
}
}