1use anyhow::{anyhow, Context, Result};
2use serde_json::{Map as JsonMap, Value as JsonValue};
3use std::path::Path;
4
5use crate::agentic::JsonPatchOp;
6
7pub fn apply_ops_to_text(input: &str, ops: &[JsonPatchOp], is_json: bool) -> Result<String> {
13 let mut doc: JsonValue = if is_json {
14 serde_json::from_str(input).context("failed to parse JSON")?
15 } else {
16 let y: serde_yaml::Value = serde_yaml::from_str(input).context("failed to parse YAML")?;
17 serde_json::to_value(y).context("failed to convert YAML->JSON")?
18 };
19
20 apply_ops_in_place(&mut doc, ops).context("failed to apply patch ops")?;
21
22 if is_json {
23 Ok(serde_json::to_string_pretty(&doc)?)
24 } else {
25 let y = serde_yaml::to_value(&doc).context("failed to convert JSON->YAML")?;
27 Ok(serde_yaml::to_string(&y)?)
28 }
29}
30
31pub fn apply_ops_to_file(path: &Path, ops: &[JsonPatchOp]) -> Result<String> {
38 use std::io::Write;
39
40 let input = std::fs::read_to_string(path)
41 .with_context(|| format!("failed to read {}", path.display()))?;
42
43 let is_json = path
44 .extension()
45 .and_then(|s| s.to_str())
46 .map(|s| s.eq_ignore_ascii_case("json"))
47 .unwrap_or(false);
48
49 let out = apply_ops_to_text(&input, ops, is_json)
50 .with_context(|| format!("failed to patch {}", path.display()))?;
51
52 let parent = path.parent().unwrap_or_else(|| Path::new("."));
53
54 let mut tmp = tempfile::Builder::new()
56 .prefix(".assay_fix_")
57 .tempfile_in(parent)
58 .context("failed to create temp file")?;
59
60 tmp.as_file_mut().write_all(out.as_bytes())?;
61 let _ = tmp.as_file_mut().sync_all(); let tmp_path = {
66 let fname = path
67 .file_name()
68 .and_then(|s| s.to_str())
69 .unwrap_or("assay_tmp");
70 parent.join(format!(".{}.assay_fix_tmp", fname))
71 };
72
73 let _ = std::fs::remove_file(&tmp_path);
74
75 let _persisted = tmp
76 .persist(&tmp_path)
77 .map_err(|e| anyhow!("failed to persist temp file: {}", e))?;
78
79 #[cfg(windows)]
80 {
81 let _ = std::fs::remove_file(path);
82 std::fs::rename(&tmp_path, path).with_context(|| {
83 format!(
84 "failed to rename {} -> {}",
85 tmp_path.display(),
86 path.display()
87 )
88 })?;
89 }
90
91 #[cfg(not(windows))]
92 {
93 std::fs::rename(&tmp_path, path).with_context(|| {
94 format!(
95 "failed to rename {} -> {}",
96 tmp_path.display(),
97 path.display()
98 )
99 })?;
100 }
101
102 Ok(out)
103}
104
105pub fn apply_ops_in_place(doc: &mut JsonValue, ops: &[JsonPatchOp]) -> Result<()> {
107 for op in ops {
108 match op {
109 JsonPatchOp::Add { path, value } => {
110 add(doc, path, value.clone())?;
111 }
112 JsonPatchOp::Remove { path } => {
113 remove(doc, path)?;
114 }
115 JsonPatchOp::Replace { path, value } => {
116 replace(doc, path, value.clone())?;
117 }
118 JsonPatchOp::Move { from, path } => {
119 let v = take(doc, from)?;
120 add(doc, path, v)?;
121 }
122 }
123 }
124 Ok(())
125}
126
127fn parse_ptr(ptr: &str) -> Result<Vec<String>> {
132 if ptr.is_empty() {
133 return Ok(vec![]);
134 }
135 if !ptr.starts_with('/') {
136 return Err(anyhow!("invalid JSON pointer (must start with /): {}", ptr));
137 }
138 Ok(ptr
139 .trim_start_matches('/')
140 .split('/')
141 .map(unescape_ptr_token)
142 .collect())
143}
144
145fn unescape_ptr_token(s: &str) -> String {
146 s.replace("~1", "/").replace("~0", "~")
147}
148
149fn is_index_token(tok: &str) -> bool {
150 tok == "-" || tok.parse::<usize>().is_ok()
151}
152
153fn type_name(v: &JsonValue) -> &'static str {
154 match v {
155 JsonValue::Null => "null",
156 JsonValue::Bool(_) => "bool",
157 JsonValue::Number(_) => "number",
158 JsonValue::String(_) => "string",
159 JsonValue::Array(_) => "array",
160 JsonValue::Object(_) => "object",
161 }
162}
163
164fn ensure_child_container<'a>(
167 parent: &'a mut JsonValue,
168 key: &str,
169 next: Option<&str>,
170) -> Result<&'a mut JsonValue> {
171 let want_array = next.map(is_index_token).unwrap_or(false);
172
173 match parent {
174 JsonValue::Object(map) => {
175 if !map.contains_key(key) || map.get(key).map(|v| v.is_null()).unwrap_or(false) {
176 map.insert(
177 key.to_string(),
178 if want_array {
179 JsonValue::Array(vec![])
180 } else {
181 JsonValue::Object(JsonMap::new())
182 },
183 );
184 } else {
185 let ok = if want_array {
187 map.get(key).map(|v| v.is_array()).unwrap_or(false)
188 } else {
189 map.get(key).map(|v| v.is_object()).unwrap_or(false)
190 };
191 if !ok {
192 map.insert(
193 key.to_string(),
194 if want_array {
195 JsonValue::Array(vec![])
196 } else {
197 JsonValue::Object(JsonMap::new())
198 },
199 );
200 }
201 }
202 Ok(map.get_mut(key).unwrap())
203 }
204 _ => Err(anyhow!(
205 "expected object while ensuring path; got {}",
206 type_name(parent)
207 )),
208 }
209}
210
211fn get_mut_loose<'a>(root: &'a mut JsonValue, tokens: &[String]) -> Result<&'a mut JsonValue> {
213 let mut cur = root;
214 for (i, tok) in tokens.iter().enumerate() {
215 let next = tokens.get(i + 1).map(|s| s.as_str());
216
217 match cur {
218 JsonValue::Object(_) => {
219 cur = ensure_child_container(cur, tok, next)?;
220 }
221 JsonValue::Array(arr) => {
222 let idx: usize = tok
223 .parse()
224 .map_err(|_| anyhow!("expected array index, got '{}'", tok))?;
225 if idx >= arr.len() {
226 return Err(anyhow!("index out of bounds while traversing: {}", tok));
227 }
228 cur = &mut arr[idx];
229 }
230 _ => return Err(anyhow!("cannot traverse into {}", type_name(cur))),
231 }
232 }
233 Ok(cur)
234}
235
236fn get_mut_strict<'a>(root: &'a mut JsonValue, tokens: &[String]) -> Result<&'a mut JsonValue> {
239 let mut cur = root;
240 for tok in tokens {
241 match cur {
242 JsonValue::Object(map) => {
243 cur = map
244 .get_mut(tok)
245 .ok_or_else(|| anyhow!("path does not exist at key '{}'", tok))?;
246 }
247 JsonValue::Array(arr) => {
248 let idx: usize = tok
249 .parse()
250 .map_err(|_| anyhow!("expected array index, got '{}'", tok))?;
251 cur = arr
252 .get_mut(idx)
253 .ok_or_else(|| anyhow!("index out of bounds: {}", idx))?;
254 }
255 _ => return Err(anyhow!("cannot traverse into {}", type_name(cur))),
256 }
257 }
258 Ok(cur)
259}
260
261fn add(root: &mut JsonValue, ptr: &str, value: JsonValue) -> Result<()> {
262 let tokens = parse_ptr(ptr)?;
263 if tokens.is_empty() {
264 *root = value;
265 return Ok(());
266 }
267
268 let (parent_tokens, last) = tokens.split_at(tokens.len() - 1);
269 let last = &last[0];
270
271 let parent = get_mut_loose(root, parent_tokens)?;
272 match parent {
273 JsonValue::Object(map) => {
274 if last == "-" {
275 return Err(anyhow!("cannot add '-' key into object"));
276 }
277 map.insert(last.to_string(), value);
278 Ok(())
279 }
280 JsonValue::Array(arr) => {
281 if last == "-" {
282 arr.push(value);
283 Ok(())
284 } else {
285 let idx: usize = last
286 .parse()
287 .map_err(|_| anyhow!("expected array index, got '{}'", last))?;
288 if idx > arr.len() {
289 return Err(anyhow!("add index out of bounds: {}", idx));
290 }
291 arr.insert(idx, value);
292 Ok(())
293 }
294 }
295 _ => Err(anyhow!(
296 "add parent must be object/array, got {}",
297 type_name(parent)
298 )),
299 }
300}
301
302fn replace(root: &mut JsonValue, ptr: &str, value: JsonValue) -> Result<()> {
303 let tokens = parse_ptr(ptr)?;
304 if tokens.is_empty() {
305 *root = value;
306 return Ok(());
307 }
308
309 let (parent_tokens, last) = tokens.split_at(tokens.len() - 1);
310 let last = &last[0];
311
312 let parent = get_mut_strict(root, parent_tokens)?;
313 match parent {
314 JsonValue::Object(map) => {
315 if !map.contains_key(last) {
316 return Err(anyhow!("replace target missing: {}", ptr));
317 }
318 map.insert(last.to_string(), value);
319 Ok(())
320 }
321 JsonValue::Array(arr) => {
322 let idx: usize = last
323 .parse()
324 .map_err(|_| anyhow!("expected array index, got '{}'", last))?;
325 if idx >= arr.len() {
326 return Err(anyhow!("replace index out of bounds: {}", idx));
327 }
328 arr[idx] = value;
329 Ok(())
330 }
331 _ => Err(anyhow!(
332 "replace parent must be object/array, got {}",
333 type_name(parent)
334 )),
335 }
336}
337
338fn remove(root: &mut JsonValue, ptr: &str) -> Result<()> {
339 let tokens = parse_ptr(ptr)?;
340 if tokens.is_empty() {
341 *root = JsonValue::Null;
342 return Ok(());
343 }
344
345 let (parent_tokens, last) = tokens.split_at(tokens.len() - 1);
346 let last = &last[0];
347
348 let parent = get_mut_strict(root, parent_tokens)?;
349 match parent {
350 JsonValue::Object(map) => {
351 map.remove(last)
352 .ok_or_else(|| anyhow!("remove target missing: {}", ptr))?;
353 Ok(())
354 }
355 JsonValue::Array(arr) => {
356 let idx: usize = last
357 .parse()
358 .map_err(|_| anyhow!("expected array index, got '{}'", last))?;
359 if idx >= arr.len() {
360 return Err(anyhow!("remove index out of bounds: {}", idx));
361 }
362 arr.remove(idx);
363 Ok(())
364 }
365 _ => Err(anyhow!(
366 "remove parent must be object/array, got {}",
367 type_name(parent)
368 )),
369 }
370}
371
372fn take(root: &mut JsonValue, ptr: &str) -> Result<JsonValue> {
373 let tokens = parse_ptr(ptr)?;
374 if tokens.is_empty() {
375 let mut tmp = JsonValue::Null;
376 std::mem::swap(&mut tmp, root);
377 return Ok(tmp);
378 }
379
380 let (parent_tokens, last) = tokens.split_at(tokens.len() - 1);
381 let last = &last[0];
382
383 let parent = get_mut_strict(root, parent_tokens)?;
384 match parent {
385 JsonValue::Object(map) => map
386 .remove(last)
387 .ok_or_else(|| anyhow!("move/from missing: {}", ptr)),
388 JsonValue::Array(arr) => {
389 let idx: usize = last
390 .parse()
391 .map_err(|_| anyhow!("expected array index, got '{}'", last))?;
392 if idx >= arr.len() {
393 return Err(anyhow!("move/from index out of bounds: {}", idx));
394 }
395 Ok(arr.remove(idx))
396 }
397 _ => Err(anyhow!(
398 "move/from parent must be object/array, got {}",
399 type_name(parent)
400 )),
401 }
402}
403
404#[cfg(test)]
405fn escape_ptr_token(s: &str) -> String {
406 s.replace('~', "~0").replace('/', "~1")
407}
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412 use serde_json::json;
413
414 #[test]
415 fn test_escape_unescape_pointer() {
416 let cases = vec![
418 ("~", "~0"),
419 ("/", "~1"),
420 ("a/b", "a~1b"),
421 ("m~n", "m~0n"),
422 ("~/", "~0~1"),
423 ("/~", "~1~0"),
424 ("foo/bar~baz", "foo~1bar~0baz"),
425 ];
426
427 for (plain, escaped) in cases {
428 assert_eq!(escape_ptr_token(plain), escaped, "Escaping '{}'", plain);
429 assert_eq!(
430 unescape_ptr_token(escaped),
431 plain,
432 "Unescaping '{}'",
433 escaped
434 );
435 }
436 }
437
438 #[test]
439 fn test_apply_patch_memory_json() {
440 let input = r#"{ "foo": "bar" }"#;
441 let ops = vec![
442 JsonPatchOp::Replace {
443 path: "/foo".into(),
444 value: json!("baz"),
445 },
446 JsonPatchOp::Add {
447 path: "/new".into(),
448 value: json!(123),
449 },
450 ];
451
452 let out = apply_ops_to_text(input, &ops, true).expect("apply success");
453 let parsed: JsonValue = serde_json::from_str(&out).unwrap();
454
455 assert_eq!(parsed["foo"], "baz");
456 assert_eq!(parsed["new"], 123);
457 }
458
459 #[test]
460 fn test_remove_strict_does_not_create_paths() {
461 let mut doc = json!({"a": {"b": 1}});
462 let ops = vec![JsonPatchOp::Remove {
463 path: "/a/missing".into(),
464 }];
465
466 let err = apply_ops_in_place(&mut doc, &ops).unwrap_err();
467 assert!(err.to_string().contains("remove target missing"));
468 assert_eq!(doc, json!({"a": {"b": 1}}));
470 }
471}