Skip to main content

agentmux_srv/backend/
ijson.rs

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