assay_core/fix/
mod.rs

1use anyhow::{anyhow, Context, Result};
2use serde_json::{Map as JsonMap, Value as JsonValue};
3use std::path::Path;
4
5use crate::agentic::JsonPatchOp;
6
7/// Apply JSON Patch ops to a YAML or JSON document string.
8/// - If `is_json == true`, parse as JSON and serialize as pretty JSON.
9/// - Else parse as YAML and serialize as YAML.
10///
11/// NOTE: YAML formatting will be normalized by serde_yaml.
12pub 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        // Convert JSON back to YAML via Serialize
26        let y = serde_yaml::to_value(&doc).context("failed to convert JSON->YAML")?;
27        Ok(serde_yaml::to_string(&y)?)
28    }
29}
30
31/// Apply JSON Patch ops to a file in-place using an atomic-ish write.
32/// Returns the new content.
33///
34/// Notes:
35/// - On Unix, `rename` overwrites atomically.
36/// - On Windows, `rename` won’t overwrite; we remove destination first.
37pub 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    // Write to a tempfile in the same dir to keep rename semantics sane.
55    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(); // best-effort
62
63    // Persist to a deterministic sibling file first, then rename over destination.
64    // This avoids Windows issues where persist-to-dest fails if dest exists.
65    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
105/// Apply a sequence of JSON Patch operations to a `serde_json::Value` in place.
106pub 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
127// -----------------------
128// JSON Pointer utilities
129// -----------------------
130
131fn 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
164/// Ensure the container for a child exists. Uses `next` token to decide array vs object.
165/// This is ONLY appropriate for "add" / "create paths" behavior.
166fn 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                // If it exists but is wrong type, overwrite (practical for fixes)
186                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
211/// "Loose" traversal that creates missing containers. Use for add/building paths.
212fn 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
236/// Strict traversal that does NOT mutate the doc if the path is missing.
237/// Use for remove/replace/take so we avoid partial mutation on failure.
238fn 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        // Test cases from RFC 6901
417        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        // Ensure original doc not mutated
469        assert_eq!(doc, json!({"a": {"b": 1}}));
470    }
471}