Skip to main content

agent_block_mcp/
lua_json.rs

1//! Lua ↔ JSON value bridge.
2//!
3//! Moved from `src/bridge/mod.rs` during the 4-crate split so that the MCP
4//! handler can reach these conversions without depending on `agent-block-core`.
5//! `agent-block-core::bridge` re-exports them for the other bridge modules
6//! (llm / mesh / mcp.lua), preserving the historical `crate::bridge::*` API.
7
8use mlua::prelude::*;
9
10/// Convert a Lua value to a serde_json::Value.
11///
12/// Round-trips with `json_to_lua` and `std.json.encode` (mlua-batteries).
13/// Lua `nil` maps to JSON `null`.  Unsupported types (functions, userdata
14/// other than `null`) yield an error so that callers do not silently emit
15/// malformed JSON.
16pub fn lua_to_json(_lua: &Lua, val: LuaValue) -> LuaResult<serde_json::Value> {
17    lua_to_json_inner(&val, 0)
18}
19
20fn lua_to_json_inner(val: &LuaValue, depth: usize) -> LuaResult<serde_json::Value> {
21    const MAX_DEPTH: usize = 128;
22    if depth > MAX_DEPTH {
23        return Err(LuaError::external(format!(
24            "Lua table nesting too deep for JSON (limit: {MAX_DEPTH})"
25        )));
26    }
27    match val {
28        LuaValue::Nil => Ok(serde_json::Value::Null),
29        // mlua serde uses LightUserData(null_ptr) for JSON null.  Treat it
30        // the same as Nil so values produced by `json_to_lua` round-trip.
31        LuaValue::LightUserData(u) if u.0.is_null() => Ok(serde_json::Value::Null),
32        LuaValue::Boolean(b) => Ok(serde_json::Value::Bool(*b)),
33        LuaValue::Integer(i) => Ok(serde_json::Value::Number((*i).into())),
34        LuaValue::Number(n) => serde_json::Number::from_f64(*n)
35            .map(serde_json::Value::Number)
36            .ok_or_else(|| LuaError::external(format!("cannot convert {n} to JSON number"))),
37        LuaValue::String(s) => Ok(serde_json::Value::String(s.to_str()?.to_string())),
38        LuaValue::Table(t) => {
39            let len = t.raw_len();
40            if len > 0 {
41                let mut arr = Vec::with_capacity(len);
42                for i in 1..=len {
43                    let v: LuaValue = t.raw_get(i)?;
44                    arr.push(lua_to_json_inner(&v, depth + 1)?);
45                }
46                Ok(serde_json::Value::Array(arr))
47            } else {
48                let mut map = serde_json::Map::new();
49                for pair in t.clone().pairs::<LuaValue, LuaValue>() {
50                    let (k, v) = pair?;
51                    let key = match k {
52                        LuaValue::String(s) => s.to_str()?.to_string(),
53                        LuaValue::Integer(i) => i.to_string(),
54                        LuaValue::Number(n) => n.to_string(),
55                        other => {
56                            return Err(LuaError::external(format!(
57                                "unsupported table key type for JSON: {}",
58                                other.type_name()
59                            )));
60                        }
61                    };
62                    map.insert(key, lua_to_json_inner(&v, depth + 1)?);
63                }
64                Ok(serde_json::Value::Object(map))
65            }
66        }
67        other => Err(LuaError::external(format!(
68            "unsupported type for JSON conversion: {}",
69            other.type_name()
70        ))),
71    }
72}
73
74/// Convert a serde_json::Value to a Lua value.
75///
76/// JSON `null` maps to the `LightUserData(null_ptr)` sentinel
77/// (`mlua::Value::NULL`), which is the same representation `lua_to_json`
78/// accepts on the way out — so the round-trip is symmetric.  Using the
79/// sentinel rather than Lua `nil` means JSON `null` values survive being
80/// placed into Lua tables (tables cannot hold `nil`), so SQL NULL columns
81/// and MCP/LLM JSON payloads do not lose the distinction between "null"
82/// and "absent".  Agents can compare a value against the exposed
83/// `std.sql.null` constant to detect it.
84///
85/// Note: this differs from mlua-batteries' `std.json.decode`, which keeps
86/// the Lua-idiomatic "null → nil" lowering for `json.decode` itself.  Our
87/// bridge paths (sql / kv / mcp / mesh / llm) prefer round-trip fidelity.
88pub fn json_to_lua(lua: &Lua, val: serde_json::Value) -> LuaResult<LuaValue> {
89    json_to_lua_inner(lua, &val, 0)
90}
91
92fn json_to_lua_inner(lua: &Lua, val: &serde_json::Value, depth: usize) -> LuaResult<LuaValue> {
93    const MAX_DEPTH: usize = 128;
94    if depth > MAX_DEPTH {
95        return Err(LuaError::external(format!(
96            "JSON nesting too deep (limit: {MAX_DEPTH})"
97        )));
98    }
99    match val {
100        serde_json::Value::Null => Ok(LuaValue::NULL),
101        serde_json::Value::Bool(b) => Ok(LuaValue::Boolean(*b)),
102        serde_json::Value::Number(n) => {
103            if let Some(i) = n.as_i64() {
104                Ok(LuaValue::Integer(i))
105            } else if let Some(f) = n.as_f64() {
106                Ok(LuaValue::Number(f))
107            } else {
108                Err(LuaError::external(format!(
109                    "JSON number {n} is not representable as i64 or f64"
110                )))
111            }
112        }
113        serde_json::Value::String(s) => lua.create_string(s).map(LuaValue::String),
114        serde_json::Value::Array(arr) => {
115            let table = lua.create_table()?;
116            for (i, v) in arr.iter().enumerate() {
117                table.set(i + 1, json_to_lua_inner(lua, v, depth + 1)?)?;
118            }
119            Ok(LuaValue::Table(table))
120        }
121        serde_json::Value::Object(map) => {
122            let table = lua.create_table()?;
123            for (k, v) in map {
124                table.set(k.as_str(), json_to_lua_inner(lua, v, depth + 1)?)?;
125            }
126            Ok(LuaValue::Table(table))
127        }
128    }
129}