use std::fmt;
use serde_json::error::Category;
use serde_json::Value;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum JsonFailure {
NoJson,
Truncated { detail: String },
InvalidJson { detail: String },
Shape { detail: String },
}
impl JsonFailure {
#[must_use]
pub fn class(&self) -> &'static str {
match self {
Self::NoJson => "no JSON",
Self::Truncated { .. } => "truncated",
Self::InvalidJson { .. } => "invalid JSON",
Self::Shape { .. } => "unexpected shape",
}
}
#[must_use]
pub fn is_truncated(&self) -> bool {
matches!(self, Self::Truncated { .. })
}
}
impl fmt::Display for JsonFailure {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NoJson => f.write_str("no JSON object in the response"),
Self::Truncated { detail } | Self::InvalidJson { detail } | Self::Shape { detail } => {
write!(f, "{}: {detail}", self.class())
}
}
}
}
pub fn first_json_object(text: &str) -> Result<serde_json::Map<String, Value>, JsonFailure> {
let text = text.trim_end();
let start = text.find('{').ok_or(JsonFailure::NoJson)?;
let mut values = serde_json::Deserializer::from_str(&text[start..]).into_iter::<Value>();
match values.next() {
Some(Ok(Value::Object(object))) => Ok(object),
Some(Err(err)) => Err(classify(&err)),
_ => Err(JsonFailure::NoJson),
}
}
#[must_use]
pub fn salvage_truncated(text: &str) -> Option<serde_json::Map<String, Value>> {
let text = text.trim_end();
let start = text.find('{')?;
let body = &text[start..];
let cut = last_complete_array_element(body)?;
let mut repaired = String::with_capacity(cut + 2);
repaired.push_str(&body[..cut]);
repaired.push_str("]}");
match serde_json::from_str::<Value>(&repaired) {
Ok(Value::Object(object)) => Some(object),
_ => None,
}
}
fn last_complete_array_element(body: &str) -> Option<usize> {
#[derive(Clone, Copy, PartialEq, Eq)]
enum Open {
Object,
Array,
}
let mut stack: Vec<Open> = Vec::new();
let mut in_string = false;
let mut escaped = false;
let mut last = None;
for (i, byte) in body.bytes().enumerate() {
if in_string {
match byte {
_ if escaped => escaped = false,
b'\\' => escaped = true,
b'"' => in_string = false,
_ => {}
}
continue;
}
match byte {
b'"' => in_string = true,
b'{' => stack.push(Open::Object),
b'[' => stack.push(Open::Array),
b'}' | b']' => {
let closing = if byte == b'}' {
Open::Object
} else {
Open::Array
};
if stack.pop() != Some(closing) {
return None;
}
if stack.is_empty() {
return None;
}
if closing == Open::Object && stack == [Open::Object, Open::Array] {
last = Some(i + 1);
}
}
_ => {}
}
}
last
}
fn classify(err: &serde_json::Error) -> JsonFailure {
match err.classify() {
Category::Eof => JsonFailure::Truncated {
detail: format!("the response ends inside the object ({err})"),
},
Category::Syntax | Category::Io => JsonFailure::InvalidJson {
detail: err.to_string(),
},
Category::Data => JsonFailure::Shape {
detail: err.to_string(),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_bare_object_parses() {
let object = first_json_object(r#"{"entities": []}"#).unwrap();
assert!(object.contains_key("entities"));
}
#[test]
fn fences_and_prose_around_the_object_are_ignored() {
let text = "Here is the extraction:\n```json\n{\"entities\": [], \"note\": \"a } in a string\"}\n```\nLet me know if you need more.";
let object = first_json_object(text).unwrap();
assert_eq!(object["note"], "a } in a string");
}
#[test]
fn a_second_object_after_the_first_is_ignored() {
let object = first_json_object(r#"{"a": 1} {"b": 2}"#).unwrap();
assert!(object.contains_key("a") && !object.contains_key("b"));
}
#[test]
fn text_without_an_object_is_no_json() {
assert_eq!(
first_json_object("VERDICT: REQUEST_CHANGES"),
Err(JsonFailure::NoJson)
);
}
#[test]
fn an_object_that_never_closes_is_truncated() {
let failure = first_json_object("```json\n{\"entities\": [{\"name\": \"PR #2").unwrap_err();
assert!(failure.is_truncated(), "{failure}");
}
#[test]
fn a_code_expression_in_place_of_a_value_is_invalid_json() {
let text = r#"{"entities":[{"name":"Kinship","type":"concept","abstract":"Bound by trust.","overview":null,"content":null,"attributes":{}}],"relationships":[[]].length ? null : null,"cases":[]}"#;
let failure = first_json_object(text).unwrap_err();
assert_eq!(failure.class(), "invalid JSON");
assert!(failure.to_string().contains("column"), "{failure}");
}
#[test]
fn a_trailing_newline_does_not_hide_a_truncation() {
let failure = first_json_object("{\"entities\": [{\"name\": \"cut\n").unwrap_err();
assert!(failure.is_truncated(), "{failure}");
}
#[test]
fn salvage_keeps_every_element_that_completed() {
let text = r#"```json
{"entities": [{"name": "A", "abstract": "has a } brace"}, {"name": "B"}], "relationships": [{"source": "A", "target": "B"}, {"source": "B", "tar"#;
let object = salvage_truncated(text).unwrap();
assert_eq!(object["entities"].as_array().unwrap().len(), 2);
assert_eq!(object["relationships"].as_array().unwrap().len(), 1);
}
#[test]
fn salvage_needs_at_least_one_complete_element() {
assert!(salvage_truncated(r#"{"entities": [{"name": "A", "abs"#).is_none());
assert!(salvage_truncated(r#"{"entities": ["#).is_none());
}
#[test]
fn salvage_refuses_text_that_was_never_truncated() {
assert!(salvage_truncated(r#"{"entities": [{"name": "A"}]} trailing"#).is_none());
assert!(salvage_truncated("no json").is_none());
}
#[test]
fn salvage_ignores_escaped_quotes_inside_strings() {
let text = r#"{"entities": [{"name": "say \"}]\" twice"}, {"name": "cut"#;
let object = salvage_truncated(text).unwrap();
assert_eq!(object["entities"][0]["name"], "say \"}]\" twice");
assert_eq!(object["entities"].as_array().unwrap().len(), 1);
}
}