agentmux_srv\backend/
ijson.rs

1#![allow(dead_code)]
2// Copyright 2025-2026, AgentMux Corp.
3// SPDX-License-Identifier: Apache-2.0
4
5//! Incremental JSON (iJSON): path-based operations on JSON data.
6//! Port of Go's pkg/ijson/ijson.go.
7
8//!
9//! iJSON allows expressing JSON updates as a series of commands:
10//! - `set`: Set a value at a path
11//! - `del`: Delete a value at a path
12//! - `append`: Append a value to an array at a path
13//!
14//! Paths are arrays of string keys and integer indices, e.g.:
15//! `["users", 0, "name"]` refers to `data.users[0].name`
16
17use serde::{Deserialize, Serialize};
18use serde_json::Value;
19
20// ---- Path type ----
21
22/// A JSON path is a sequence of string keys and integer indices.
23/// Example: `["users", 0, "name"]` → `data.users[0].name`
24pub type Path = Vec<PathElement>;
25
26/// Element of a JSON path: either a string key or integer index.
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(untagged)]
29pub enum PathElement {
30    Key(String),
31    Index(usize),
32}
33
34impl PathElement {
35    pub fn key(s: &str) -> Self {
36        PathElement::Key(s.to_string())
37    }
38
39    pub fn index(i: usize) -> Self {
40        PathElement::Index(i)
41    }
42}
43
44impl std::fmt::Display for PathElement {
45    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        match self {
47            PathElement::Key(k) => write!(f, ".{k}"),
48            PathElement::Index(i) => write!(f, "[{i}]"),
49        }
50    }
51}
52
53// ---- Command types ----
54
55/// Command type constants.
56pub const CMD_SET: &str = "set";
57pub const CMD_DEL: &str = "del";
58pub const CMD_APPEND: &str = "append";
59
60/// An iJSON command: set, delete, or append.
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct Command {
63    /// Command type: "set", "del", or "append".
64    #[serde(rename = "type")]
65    pub cmd_type: String,
66    /// Path to target element.
67    pub path: Vec<Value>,
68    /// Data payload (for set and append commands).
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub data: Option<Value>,
71}
72
73impl Command {
74    /// Create a set command.
75    pub fn set(path: Path, data: Value) -> Self {
76        Self {
77            cmd_type: CMD_SET.to_string(),
78            path: path_to_values(&path),
79            data: Some(data),
80        }
81    }
82
83    /// Create a delete command.
84    pub fn del(path: Path) -> Self {
85        Self {
86            cmd_type: CMD_DEL.to_string(),
87            path: path_to_values(&path),
88            data: None,
89        }
90    }
91
92    /// Create an append command.
93    pub fn append(path: Path, data: Value) -> Self {
94        Self {
95            cmd_type: CMD_APPEND.to_string(),
96            path: path_to_values(&path),
97            data: Some(data),
98        }
99    }
100
101    /// Parse the path from JSON values to PathElements.
102    pub fn parsed_path(&self) -> Result<Path, String> {
103        values_to_path(&self.path)
104    }
105}
106
107// ---- Options ----
108
109/// Options for set_path operations.
110#[derive(Debug, Clone, Default)]
111pub struct SetPathOpts {
112    /// Allocation budget (0 = unlimited, negative = fail immediately).
113    pub budget: i64,
114    /// Force: clobber incompatible types at path nodes.
115    pub force: bool,
116    /// Remove: delete the value at path instead of setting it.
117    pub remove: bool,
118}
119
120// ---- Errors ----
121
122/// Error type for iJSON operations.
123#[derive(Debug, Clone)]
124pub enum IJsonError {
125    PathError(String),
126    TypeError(String),
127    BudgetExceeded,
128    IndexOutOfBounds { index: usize, len: usize },
129}
130
131impl std::fmt::Display for IJsonError {
132    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
133        match self {
134            IJsonError::PathError(msg) => write!(f, "path error: {msg}"),
135            IJsonError::TypeError(msg) => write!(f, "type error: {msg}"),
136            IJsonError::BudgetExceeded => write!(f, "budget exceeded"),
137            IJsonError::IndexOutOfBounds { index, len } => {
138                write!(f, "index {index} out of bounds (len {len})")
139            }
140        }
141    }
142}
143
144impl std::error::Error for IJsonError {}
145
146impl From<String> for IJsonError {
147    fn from(s: String) -> Self {
148        IJsonError::PathError(s)
149    }
150}
151
152// ---- Core operations ----
153
154/// Get a value at the specified path within JSON data.
155/// Returns `None` if the path doesn't exist (not an error).
156pub fn get_path(data: &Value, path: &Path) -> Result<Option<Value>, IJsonError> {
157    let mut current = data;
158    for elem in path {
159        match elem {
160            PathElement::Key(key) => match current {
161                Value::Object(map) => match map.get(key) {
162                    Some(v) => current = v,
163                    None => return Ok(None),
164                },
165                _ => return Ok(None),
166            },
167            PathElement::Index(idx) => match current {
168                Value::Array(arr) => match arr.get(*idx) {
169                    Some(v) => current = v,
170                    None => return Ok(None),
171                },
172                _ => return Ok(None),
173            },
174        }
175    }
176    Ok(Some(current.clone()))
177}
178
179/// Set a value at the specified path within JSON data.
180/// Creates intermediate objects/arrays as needed.
181/// Returns the modified data.
182pub fn set_path(
183    data: Value,
184    path: &Path,
185    value: Value,
186    opts: &SetPathOpts,
187) -> Result<Value, IJsonError> {
188    if opts.budget < 0 {
189        return Err(IJsonError::BudgetExceeded);
190    }
191
192    if path.is_empty() {
193        if opts.remove {
194            return Ok(Value::Null);
195        }
196        return Ok(value);
197    }
198
199    set_path_recursive(data, path, 0, value, opts)
200}
201
202fn set_path_recursive(
203    data: Value,
204    path: &Path,
205    depth: usize,
206    value: Value,
207    opts: &SetPathOpts,
208) -> Result<Value, IJsonError> {
209    if depth >= path.len() {
210        if opts.remove {
211            return Ok(Value::Null);
212        }
213        return Ok(value);
214    }
215
216    let elem = &path[depth];
217    let is_last = depth == path.len() - 1;
218
219    match elem {
220        PathElement::Key(key) => {
221            let mut map = match data {
222                Value::Object(m) => m,
223                Value::Null if opts.force || depth > 0 => serde_json::Map::new(),
224                _ if opts.force => serde_json::Map::new(),
225                _ => {
226                    return Err(IJsonError::TypeError(format!(
227                        "expected object at path depth {depth}, got {}",
228                        value_type_name(&data)
229                    )));
230                }
231            };
232
233            if is_last && opts.remove {
234                map.remove(key);
235            } else if is_last {
236                map.insert(key.clone(), value);
237            } else {
238                let child = map.remove(key).unwrap_or(Value::Null);
239                let new_child = set_path_recursive(child, path, depth + 1, value, opts)?;
240                map.insert(key.clone(), new_child);
241            }
242
243            Ok(Value::Object(map))
244        }
245        PathElement::Index(idx) => {
246            let mut arr = match data {
247                Value::Array(a) => a,
248                Value::Null if opts.force || depth > 0 => Vec::new(),
249                _ if opts.force => Vec::new(),
250                _ => {
251                    return Err(IJsonError::TypeError(format!(
252                        "expected array at path depth {depth}, got {}",
253                        value_type_name(&data)
254                    )));
255                }
256            };
257
258            // Extend array if needed
259            while arr.len() <= *idx {
260                arr.push(Value::Null);
261            }
262
263            if is_last && opts.remove {
264                if *idx < arr.len() {
265                    arr.remove(*idx);
266                }
267            } else if is_last {
268                arr[*idx] = value;
269            } else {
270                let child = std::mem::replace(&mut arr[*idx], Value::Null);
271                let new_child = set_path_recursive(child, path, depth + 1, value, opts)?;
272                arr[*idx] = new_child;
273            }
274
275            Ok(Value::Array(arr))
276        }
277    }
278}
279
280/// Apply a single iJSON command to data.
281pub fn apply_command(data: Value, cmd: &Command) -> Result<Value, IJsonError> {
282    let path = cmd.parsed_path()?;
283
284    match cmd.cmd_type.as_str() {
285        CMD_SET => {
286            let value = cmd.data.clone().unwrap_or(Value::Null);
287            set_path(
288                data,
289                &path,
290                value,
291                &SetPathOpts {
292                    force: true,
293                    ..Default::default()
294                },
295            )
296        }
297        CMD_DEL => set_path(
298            data,
299            &path,
300            Value::Null,
301            &SetPathOpts {
302                remove: true,
303                force: true,
304                ..Default::default()
305            },
306        ),
307        CMD_APPEND => {
308            let value = cmd.data.clone().unwrap_or(Value::Null);
309            // Get current array at path, append, set back
310            let current = get_path(&data, &path)?;
311            let mut arr = match current {
312                Some(Value::Array(a)) => a,
313                Some(Value::Null) | None => Vec::new(),
314                Some(_) => {
315                    return Err(IJsonError::TypeError(
316                        "append target is not an array".to_string(),
317                    ))
318                }
319            };
320            arr.push(value);
321            set_path(
322                data,
323                &path,
324                Value::Array(arr),
325                &SetPathOpts {
326                    force: true,
327                    ..Default::default()
328                },
329            )
330        }
331        other => Err(IJsonError::PathError(format!(
332            "unknown command type: {other}"
333        ))),
334    }
335}
336
337/// Apply a sequence of iJSON commands to data.
338pub fn apply_commands(data: Value, commands: &[Command]) -> Result<Value, IJsonError> {
339    let mut result = data;
340    for cmd in commands {
341        result = apply_command(result, cmd)?;
342    }
343    Ok(result)
344}
345
346// ---- Path formatting ----
347
348/// Format a path for display: `$users[0].name`
349pub fn format_path(path: &Path) -> String {
350    let mut result = String::from("$");
351    for elem in path {
352        match elem {
353            PathElement::Key(k) => {
354                // Use bracket notation for keys with special chars
355                if k.chars()
356                    .all(|c| c.is_ascii_alphanumeric() || c == '_')
357                    && !k.is_empty()
358                    && !k.chars().next().unwrap().is_ascii_digit()
359                {
360                    result.push('.');
361                    result.push_str(k);
362                } else {
363                    result.push_str(&format!("[\"{k}\"]"));
364                }
365            }
366            PathElement::Index(i) => {
367                result.push_str(&format!("[{i}]"));
368            }
369        }
370    }
371    result
372}
373
374/// Parse a simple dot-separated path string.
375/// "users.0.name" → [Key("users"), Index(0), Key("name")]
376pub fn parse_simple_path(s: &str) -> Path {
377    if s.is_empty() {
378        return vec![];
379    }
380    s.split('.')
381        .map(|part| {
382            if let Ok(idx) = part.parse::<usize>() {
383                PathElement::Index(idx)
384            } else {
385                PathElement::Key(part.to_string())
386            }
387        })
388        .collect()
389}
390
391// ---- Helpers ----
392
393/// Convert Path to JSON values for serialization.
394fn path_to_values(path: &Path) -> Vec<Value> {
395    path.iter()
396        .map(|elem| match elem {
397            PathElement::Key(k) => Value::String(k.clone()),
398            PathElement::Index(i) => Value::Number((*i as u64).into()),
399        })
400        .collect()
401}
402
403/// Convert JSON values to Path.
404fn values_to_path(values: &[Value]) -> Result<Path, String> {
405    values
406        .iter()
407        .enumerate()
408        .map(|(i, v)| match v {
409            Value::String(s) => Ok(PathElement::Key(s.clone())),
410            Value::Number(n) => {
411                if let Some(idx) = n.as_u64() {
412                    Ok(PathElement::Index(idx as usize))
413                } else {
414                    Err(format!("path element {i}: expected non-negative integer"))
415                }
416            }
417            _ => Err(format!(
418                "path element {i}: expected string or number, got {}",
419                value_type_name(v)
420            )),
421        })
422        .collect::<Result<Path, String>>()
423        .map_err(|e| e.to_string())
424}
425
426/// Get a human-readable type name for a JSON value.
427fn value_type_name(v: &Value) -> &'static str {
428    match v {
429        Value::Null => "null",
430        Value::Bool(_) => "bool",
431        Value::Number(_) => "number",
432        Value::String(_) => "string",
433        Value::Array(_) => "array",
434        Value::Object(_) => "object",
435    }
436}
437
438/// Parse newline-delimited iJSON commands.
439pub fn parse_ijson(data: &str) -> Result<Vec<Command>, String> {
440    let mut commands = Vec::new();
441    for (i, line) in data.lines().enumerate() {
442        let line = line.trim();
443        if line.is_empty() {
444            continue;
445        }
446        let cmd: Command = serde_json::from_str(line)
447            .map_err(|e| format!("line {}: {e}", i + 1))?;
448        commands.push(cmd);
449    }
450    Ok(commands)
451}
452
453/// Compact iJSON: apply all commands and produce a single set command.
454pub fn compact_ijson(data: &str) -> Result<String, String> {
455    let commands = parse_ijson(data)?;
456    if commands.is_empty() {
457        return Ok(String::new());
458    }
459    let result = apply_commands(Value::Null, &commands)
460        .map_err(|e| format!("apply error: {e}"))?;
461    let compact_cmd = Command::set(vec![], result);
462    serde_json::to_string(&compact_cmd).map_err(|e| format!("serialize error: {e}"))
463}
464
465#[cfg(test)]
466mod tests {
467    use super::*;
468    use serde_json::json;
469
470    #[test]
471    fn test_path_element_display() {
472        assert_eq!(PathElement::key("name").to_string(), ".name");
473        assert_eq!(PathElement::index(0).to_string(), "[0]");
474    }
475
476    #[test]
477    fn test_format_path() {
478        let path = vec![
479            PathElement::key("users"),
480            PathElement::index(0),
481            PathElement::key("name"),
482        ];
483        assert_eq!(format_path(&path), "$.users[0].name");
484    }
485
486    #[test]
487    fn test_format_path_empty() {
488        assert_eq!(format_path(&vec![]), "$");
489    }
490
491    #[test]
492    fn test_format_path_special_key() {
493        let path = vec![PathElement::key("my-key")];
494        assert_eq!(format_path(&path), "$[\"my-key\"]");
495    }
496
497    #[test]
498    fn test_parse_simple_path() {
499        let path = parse_simple_path("users.0.name");
500        assert_eq!(
501            path,
502            vec![
503                PathElement::key("users"),
504                PathElement::index(0),
505                PathElement::key("name"),
506            ]
507        );
508    }
509
510    #[test]
511    fn test_parse_simple_path_empty() {
512        assert!(parse_simple_path("").is_empty());
513    }
514
515    #[test]
516    fn test_get_path_object() {
517        let data = json!({"users": [{"name": "Alice"}, {"name": "Bob"}]});
518        let path = vec![
519            PathElement::key("users"),
520            PathElement::index(0),
521            PathElement::key("name"),
522        ];
523        let result = get_path(&data, &path).unwrap();
524        assert_eq!(result, Some(json!("Alice")));
525    }
526
527    #[test]
528    fn test_get_path_missing() {
529        let data = json!({"a": 1});
530        let path = vec![PathElement::key("b")];
531        let result = get_path(&data, &path).unwrap();
532        assert_eq!(result, None);
533    }
534
535    #[test]
536    fn test_get_path_empty() {
537        let data = json!({"a": 1});
538        let result = get_path(&data, &vec![]).unwrap();
539        assert_eq!(result, Some(json!({"a": 1})));
540    }
541
542    #[test]
543    fn test_get_path_deep_missing() {
544        let data = json!({"a": {"b": 1}});
545        let path = vec![PathElement::key("a"), PathElement::key("c")];
546        let result = get_path(&data, &path).unwrap();
547        assert_eq!(result, None);
548    }
549
550    #[test]
551    fn test_set_path_simple() {
552        let data = json!({});
553        let path = vec![PathElement::key("name")];
554        let result = set_path(data, &path, json!("Alice"), &SetPathOpts::default()).unwrap();
555        assert_eq!(result, json!({"name": "Alice"}));
556    }
557
558    #[test]
559    fn test_set_path_nested() {
560        let data = json!({});
561        let path = vec![PathElement::key("a"), PathElement::key("b")];
562        let result = set_path(
563            data,
564            &path,
565            json!(42),
566            &SetPathOpts {
567                force: true,
568                ..Default::default()
569            },
570        )
571        .unwrap();
572        assert_eq!(result, json!({"a": {"b": 42}}));
573    }
574
575    #[test]
576    fn test_set_path_array() {
577        let data = json!({"items": [1, 2, 3]});
578        let path = vec![PathElement::key("items"), PathElement::index(1)];
579        let result = set_path(data, &path, json!(99), &SetPathOpts::default()).unwrap();
580        assert_eq!(result, json!({"items": [1, 99, 3]}));
581    }
582
583    #[test]
584    fn test_set_path_array_extend() {
585        let data = json!({"items": []});
586        let path = vec![PathElement::key("items"), PathElement::index(2)];
587        let result = set_path(data, &path, json!("new"), &SetPathOpts::default()).unwrap();
588        assert_eq!(result, json!({"items": [null, null, "new"]}));
589    }
590
591    #[test]
592    fn test_set_path_remove() {
593        let data = json!({"a": 1, "b": 2});
594        let path = vec![PathElement::key("a")];
595        let result = set_path(
596            data,
597            &path,
598            Value::Null,
599            &SetPathOpts {
600                remove: true,
601                ..Default::default()
602            },
603        )
604        .unwrap();
605        assert_eq!(result, json!({"b": 2}));
606    }
607
608    #[test]
609    fn test_set_path_empty_path() {
610        let data = json!({"old": true});
611        let result =
612            set_path(data, &vec![], json!({"new": true}), &SetPathOpts::default()).unwrap();
613        assert_eq!(result, json!({"new": true}));
614    }
615
616    #[test]
617    fn test_set_path_budget_exceeded() {
618        let data = json!({});
619        let path = vec![PathElement::key("a")];
620        let result = set_path(
621            data,
622            &path,
623            json!(1),
624            &SetPathOpts {
625                budget: -1,
626                ..Default::default()
627            },
628        );
629        assert!(matches!(result, Err(IJsonError::BudgetExceeded)));
630    }
631
632    #[test]
633    fn test_command_set() {
634        let cmd = Command::set(
635            vec![PathElement::key("name")],
636            json!("Alice"),
637        );
638        assert_eq!(cmd.cmd_type, CMD_SET);
639        let json = serde_json::to_string(&cmd).unwrap();
640        assert!(json.contains("\"type\":\"set\""));
641    }
642
643    #[test]
644    fn test_command_del() {
645        let cmd = Command::del(vec![PathElement::key("name")]);
646        assert_eq!(cmd.cmd_type, CMD_DEL);
647        assert!(cmd.data.is_none());
648    }
649
650    #[test]
651    fn test_command_append() {
652        let cmd = Command::append(vec![PathElement::key("items")], json!(42));
653        assert_eq!(cmd.cmd_type, CMD_APPEND);
654    }
655
656    #[test]
657    fn test_apply_command_set() {
658        let data = json!({});
659        let cmd = Command::set(vec![PathElement::key("x")], json!(1));
660        let result = apply_command(data, &cmd).unwrap();
661        assert_eq!(result, json!({"x": 1}));
662    }
663
664    #[test]
665    fn test_apply_command_del() {
666        let data = json!({"x": 1, "y": 2});
667        let cmd = Command::del(vec![PathElement::key("x")]);
668        let result = apply_command(data, &cmd).unwrap();
669        assert_eq!(result, json!({"y": 2}));
670    }
671
672    #[test]
673    fn test_apply_command_append() {
674        let data = json!({"items": [1, 2]});
675        let cmd = Command::append(vec![PathElement::key("items")], json!(3));
676        let result = apply_command(data, &cmd).unwrap();
677        assert_eq!(result, json!({"items": [1, 2, 3]}));
678    }
679
680    #[test]
681    fn test_apply_command_append_create() {
682        let data = json!({});
683        let cmd = Command::append(vec![PathElement::key("items")], json!(1));
684        let result = apply_command(data, &cmd).unwrap();
685        assert_eq!(result, json!({"items": [1]}));
686    }
687
688    #[test]
689    fn test_apply_commands_sequence() {
690        let commands = vec![
691            Command::set(vec![], json!({})),
692            Command::set(vec![PathElement::key("name")], json!("Alice")),
693            Command::set(vec![PathElement::key("age")], json!(30)),
694        ];
695        let result = apply_commands(Value::Null, &commands).unwrap();
696        assert_eq!(result, json!({"name": "Alice", "age": 30}));
697    }
698
699    #[test]
700    fn test_parse_ijson() {
701        let input = r#"{"type":"set","path":[],"data":{}}
702{"type":"set","path":["x"],"data":1}"#;
703        let commands = parse_ijson(input).unwrap();
704        assert_eq!(commands.len(), 2);
705        assert_eq!(commands[0].cmd_type, CMD_SET);
706        assert_eq!(commands[1].cmd_type, CMD_SET);
707    }
708
709    #[test]
710    fn test_parse_ijson_empty_lines() {
711        let input = "\n\n{\"type\":\"set\",\"path\":[],\"data\":1}\n\n";
712        let commands = parse_ijson(input).unwrap();
713        assert_eq!(commands.len(), 1);
714    }
715
716    #[test]
717    fn test_compact_ijson() {
718        let input = r#"{"type":"set","path":[],"data":{}}
719{"type":"set","path":["x"],"data":1}
720{"type":"set","path":["y"],"data":2}"#;
721        let result = compact_ijson(input).unwrap();
722        let cmd: Command = serde_json::from_str(&result).unwrap();
723        assert_eq!(cmd.cmd_type, CMD_SET);
724        let data = cmd.data.unwrap();
725        assert_eq!(data, json!({"x": 1, "y": 2}));
726    }
727
728    #[test]
729    fn test_command_serde_roundtrip() {
730        let cmd = Command::set(
731            vec![PathElement::key("a"), PathElement::index(0)],
732            json!({"nested": true}),
733        );
734        let json = serde_json::to_string(&cmd).unwrap();
735        let parsed: Command = serde_json::from_str(&json).unwrap();
736        assert_eq!(parsed.cmd_type, CMD_SET);
737        assert_eq!(parsed.path.len(), 2);
738    }
739
740    #[test]
741    fn test_path_element_serde() {
742        let path = vec![PathElement::key("users"), PathElement::index(0)];
743        let json = serde_json::to_string(&path).unwrap();
744        assert_eq!(json, r#"["users",0]"#);
745        let parsed: Path = serde_json::from_str(&json).unwrap();
746        assert_eq!(parsed, path);
747    }
748
749    #[test]
750    fn test_ijson_error_display() {
751        assert_eq!(
752            IJsonError::PathError("bad path".to_string()).to_string(),
753            "path error: bad path"
754        );
755        assert_eq!(IJsonError::BudgetExceeded.to_string(), "budget exceeded");
756        assert_eq!(
757            IJsonError::IndexOutOfBounds { index: 5, len: 3 }.to_string(),
758            "index 5 out of bounds (len 3)"
759        );
760    }
761
762    #[test]
763    fn test_set_path_force_clobber() {
764        // Setting a nested path through a non-object value with force=true
765        let data = json!({"a": "string_value"});
766        let path = vec![PathElement::key("a"), PathElement::key("b")];
767        let result = set_path(
768            data,
769            &path,
770            json!(1),
771            &SetPathOpts {
772                force: true,
773                ..Default::default()
774            },
775        )
776        .unwrap();
777        assert_eq!(result, json!({"a": {"b": 1}}));
778    }
779
780    #[test]
781    fn test_value_type_name() {
782        assert_eq!(value_type_name(&Value::Null), "null");
783        assert_eq!(value_type_name(&json!(true)), "bool");
784        assert_eq!(value_type_name(&json!(42)), "number");
785        assert_eq!(value_type_name(&json!("hi")), "string");
786        assert_eq!(value_type_name(&json!([])), "array");
787        assert_eq!(value_type_name(&json!({})), "object");
788    }
789}