gglib_core/normalize/
coerce.rs1use serde_json::Value;
33
34const MAX_NESTING: usize = 64;
40
41#[must_use]
46pub(crate) fn coerce_json_object(body: &str) -> Option<String> {
47 let candidate = strip_packaging(body);
48 if candidate.is_empty() {
49 return None;
50 }
51
52 if parses_as_object(&candidate) {
54 return Some(candidate);
55 }
56
57 let without_commas = drop_trailing_commas(&candidate);
58 if parses_as_object(&without_commas) {
59 return Some(without_commas);
60 }
61
62 let closed = close_delimiters(&without_commas)?;
63 parses_as_object(&closed).then_some(closed)
64}
65
66fn parses_as_object(text: &str) -> bool {
67 serde_json::from_str::<Value>(text).is_ok_and(|v| v.is_object())
68}
69
70fn strip_packaging(body: &str) -> String {
75 let mut text = body.trim();
76
77 if let Some(rest) = text.strip_prefix("```") {
79 let rest = rest.strip_prefix("json").unwrap_or(rest);
80 text = rest.trim_start_matches(['\r', '\n']).trim();
81 if let Some(stripped) = text.strip_suffix("```") {
82 text = stripped.trim();
83 }
84 }
85
86 match (text.find('{'), text.rfind('}')) {
89 (Some(start), Some(end)) if end > start => text[start..=end].trim().to_owned(),
90 (Some(start), _) => text[start..].trim().to_owned(),
93 _ => String::new(),
94 }
95}
96
97fn drop_trailing_commas(text: &str) -> String {
101 let mut out = String::with_capacity(text.len());
102 let mut pending_comma: Option<usize> = None;
103 let mut scan = StringScan::default();
104
105 for ch in text.chars() {
106 if scan.step(ch) {
107 if let Some(idx) = pending_comma.take() {
109 out.insert(idx, ',');
110 }
111 out.push(ch);
112 continue;
113 }
114 match ch {
115 ',' => {
116 if let Some(idx) = pending_comma.take() {
117 out.insert(idx, ',');
118 }
119 pending_comma = Some(out.len());
120 }
121 '}' | ']' => {
122 pending_comma = None;
123 out.push(ch);
124 }
125 c if c.is_whitespace() => out.push(c),
126 c => {
127 if let Some(idx) = pending_comma.take() {
128 out.insert(idx, ',');
129 }
130 out.push(c);
131 }
132 }
133 }
134 if let Some(idx) = pending_comma {
135 out.insert(idx, ',');
136 }
137 out
138}
139
140fn close_delimiters(text: &str) -> Option<String> {
146 let mut stack: Vec<char> = Vec::new();
147 let mut scan = StringScan::default();
148
149 for ch in text.chars() {
150 if scan.step(ch) {
151 continue;
152 }
153 match ch {
154 '{' | '[' => {
155 if stack.len() >= MAX_NESTING {
156 return None;
157 }
158 stack.push(ch);
159 }
160 '}' if stack.pop() != Some('{') => return None,
161 ']' if stack.pop() != Some('[') => return None,
162 _ => {}
163 }
164 }
165
166 if scan.in_string {
168 return None;
169 }
170 if stack.is_empty() {
171 return None; }
173
174 let mut out = text.trim_end().to_owned();
175 while let Some(open) = stack.pop() {
176 out.push(if open == '{' { '}' } else { ']' });
177 }
178 Some(out)
179}
180
181#[derive(Default)]
183struct StringScan {
184 in_string: bool,
185 escaped: bool,
186}
187
188impl StringScan {
189 const fn step(&mut self, ch: char) -> bool {
192 if self.in_string {
193 if self.escaped {
194 self.escaped = false;
195 } else if ch == '\\' {
196 self.escaped = true;
197 } else if ch == '"' {
198 self.in_string = false;
199 return true; }
201 return true;
202 }
203 if ch == '"' {
204 self.in_string = true;
205 return true;
206 }
207 false
208 }
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214
215 const GOOD: &str = r#"{"name":"read_file","arguments":{"path":"a"}}"#;
216
217 #[test]
218 fn a_code_fence_is_unwrapped() {
219 let fenced = format!("```json\n{GOOD}\n```");
220 assert_eq!(coerce_json_object(&fenced).as_deref(), Some(GOOD));
221 }
222
223 #[test]
224 fn prose_either_side_is_dropped() {
225 let chatty = format!("Sure, I'll read it:\n{GOOD}\nLet me know!");
226 assert_eq!(coerce_json_object(&chatty).as_deref(), Some(GOOD));
227 }
228
229 #[test]
230 fn a_trailing_comma_is_removed() {
231 let sloppy = r#"{"name":"read_file","arguments":{"path":"a"},}"#;
232 assert!(coerce_json_object(sloppy).is_some());
233 }
234
235 #[test]
236 fn missing_closing_braces_are_appended() {
237 let cut = r#"{"name":"read_file","arguments":{"path":"a"}"#;
238 assert_eq!(coerce_json_object(cut).as_deref(), Some(GOOD));
239 }
240
241 #[test]
244 fn an_unterminated_string_is_never_completed() {
245 let truncated = r#"{"name":"read_file","arguments":{"path":"/etc/ho"#;
246 assert_eq!(coerce_json_object(truncated), None);
247 }
248
249 #[test]
250 fn a_comma_inside_a_string_survives() {
251 let body = r#"{"name":"say","arguments":{"text":"a, b, }"}}"#;
252 let out = coerce_json_object(body).expect("already valid");
253 assert!(out.contains("a, b, }"), "string content altered: {out}");
254 }
255
256 #[test]
257 fn mismatched_delimiters_are_refused() {
258 assert_eq!(coerce_json_object(r#"{"name":"x","args":[}"#), None);
259 }
260
261 #[test]
262 fn a_non_object_is_refused() {
263 assert_eq!(coerce_json_object("[1, 2, 3]"), None);
264 assert_eq!(coerce_json_object("\"just a string\""), None);
265 }
266
267 #[test]
268 fn pathological_nesting_is_refused() {
269 let deep = "[".repeat(MAX_NESTING + 5);
270 assert_eq!(coerce_json_object(&deep), None);
271 }
272
273 #[test]
274 fn text_with_no_object_at_all_is_refused() {
275 assert_eq!(coerce_json_object("I could not do that."), None);
276 assert_eq!(coerce_json_object(""), None);
277 }
278
279 #[test]
282 fn an_already_valid_body_is_returned_unchanged() {
283 assert_eq!(coerce_json_object(GOOD).as_deref(), Some(GOOD));
284 }
285}