1use serde::de::DeserializeOwned;
9
10pub fn strip_code_fence(raw: &str) -> &str {
13 let trimmed = raw.trim();
14 let Some(after_open) = trimmed.strip_prefix("```") else {
15 return trimmed;
16 };
17
18 let after_lang = match after_open.find('\n') {
20 Some(nl) => &after_open[nl + 1..],
21 None => after_open,
22 };
23
24 after_lang
26 .trim_end()
27 .strip_suffix("```")
28 .unwrap_or(after_lang)
29 .trim()
30}
31
32pub fn parse_json<T: DeserializeOwned>(content: &str) -> Option<T> {
39 let payload = strip_code_fence(content);
40 if payload.is_empty() {
41 return None;
42 }
43 match serde_json::from_str(payload) {
44 Ok(v) => Some(v),
45 Err(err) => {
46 tracing::warn!(target: "llm.json", error = %err, "parse_json_failed");
47 None
48 }
49 }
50}
51
52#[cfg(test)]
53mod tests {
54 use super::*;
55 use serde::Deserialize;
56
57 #[derive(Debug, Deserialize, PartialEq)]
58 struct Verdict {
59 ok: bool,
60 score: f64,
61 }
62
63 #[test]
64 fn parses_bare_json() {
65 let v: Verdict = parse_json(r#"{"ok": true, "score": 0.9}"#).unwrap();
66 assert_eq!(
67 v,
68 Verdict {
69 ok: true,
70 score: 0.9
71 }
72 );
73 }
74
75 #[test]
76 fn parses_fenced_json_with_lang_tag() {
77 let raw = "```json\n{\"ok\": false, \"score\": 0.1}\n```";
78 let v: Verdict = parse_json(raw).unwrap();
79 assert_eq!(
80 v,
81 Verdict {
82 ok: false,
83 score: 0.1
84 }
85 );
86 }
87
88 #[test]
89 fn parses_fenced_json_without_lang_tag() {
90 let raw = "```\n{\"ok\": true, \"score\": 1.0}\n```";
91 let v: Verdict = parse_json(raw).unwrap();
92 assert!(v.ok);
93 }
94
95 #[test]
96 fn returns_none_on_garbage() {
97 assert!(parse_json::<Verdict>("not json at all").is_none());
98 assert!(parse_json::<Verdict>("").is_none());
99 }
100}