use serde::de::DeserializeOwned;
pub fn strip_code_fence(raw: &str) -> &str {
let trimmed = raw.trim();
let Some(after_open) = trimmed.strip_prefix("```") else {
return trimmed;
};
let after_lang = match after_open.find('\n') {
Some(nl) => &after_open[nl + 1..],
None => after_open,
};
after_lang
.trim_end()
.strip_suffix("```")
.unwrap_or(after_lang)
.trim()
}
pub fn parse_json<T: DeserializeOwned>(content: &str) -> Option<T> {
let payload = strip_code_fence(content);
if payload.is_empty() {
return None;
}
match serde_json::from_str(payload) {
Ok(v) => Some(v),
Err(err) => {
tracing::warn!(target: "llm.json", error = %err, "parse_json_failed");
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde::Deserialize;
#[derive(Debug, Deserialize, PartialEq)]
struct Verdict {
ok: bool,
score: f64,
}
#[test]
fn parses_bare_json() {
let v: Verdict = parse_json(r#"{"ok": true, "score": 0.9}"#).unwrap();
assert_eq!(
v,
Verdict {
ok: true,
score: 0.9
}
);
}
#[test]
fn parses_fenced_json_with_lang_tag() {
let raw = "```json\n{\"ok\": false, \"score\": 0.1}\n```";
let v: Verdict = parse_json(raw).unwrap();
assert_eq!(
v,
Verdict {
ok: false,
score: 0.1
}
);
}
#[test]
fn parses_fenced_json_without_lang_tag() {
let raw = "```\n{\"ok\": true, \"score\": 1.0}\n```";
let v: Verdict = parse_json(raw).unwrap();
assert!(v.ok);
}
#[test]
fn returns_none_on_garbage() {
assert!(parse_json::<Verdict>("not json at all").is_none());
assert!(parse_json::<Verdict>("").is_none());
}
}