diff --git a/crates/aurorality-core/src/lib.rs b/crates/aurorality-core/src/lib.rs index 22fe120..7d32c90 100644 --- a/crates/aurorality-core/src/lib.rs +++ b/crates/aurorality-core/src/lib.rs @@ -163,4 +163,33 @@ mod tests { let v: serde_json::Value = serde_json::from_str(&result).unwrap(); assert_eq!(v["ok"], false); } + + #[test] + fn matrix_list_uses_single_envelope() { + let result = plugin_invoke("matrix".into(), "list".into(), "{}".into()).unwrap(); + let v: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!(v["ok"], true); + assert!(v["data"].is_array(), "expected a message array, got {v}"); + assert!(v["data"].get("ok").is_none()); + } + + #[test] + fn matrix_send_empty_uses_single_envelope() { + let result = plugin_invoke("matrix".into(), "send".into(), "{}".into()).unwrap(); + let v: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!(v["ok"], true); + assert_eq!(v["data"]["accepted"], false); + assert_eq!(v["data"]["reason"], "empty"); + assert!(v["data"].get("data").is_none()); + } + + #[test] + fn stalwart_send_empty_uses_single_envelope() { + let result = plugin_invoke("stalwart".into(), "send".into(), "{}".into()).unwrap(); + let v: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!(v["ok"], true); + assert_eq!(v["data"]["accepted"], false); + assert_eq!(v["data"]["reason"], "empty"); + assert!(v["data"].get("data").is_none()); + } } diff --git a/crates/aurorality-core/src/mutations.rs b/crates/aurorality-core/src/mutations.rs index c04d4b1..87a18ff 100644 --- a/crates/aurorality-core/src/mutations.rs +++ b/crates/aurorality-core/src/mutations.rs @@ -87,29 +87,23 @@ fn int_path_field(m: &Value, field: &str) -> Result, String> { // ── Tree operations ─────────────────────────────────────────────────────────── -// NOTE: The raw pointer casts below (`as *mut Vec`) are needed to work -// around the borrow checker when recursing through `serde_json::Value`. They -// are safe because we only ever follow a single path through the tree at a time -// and never create aliasing mutable references. - -#[allow(clippy::ptr_arg)] -fn replace_at(nodes: &mut Vec, path: &[usize], replacement: Value) { - let Some(&first) = path.first() else { return }; +fn children_mut(node: &mut Value) -> Option<&mut Vec> { + node.get_mut("children")?.as_array_mut() +} + +fn replace_at(nodes: &mut [Value], path: &[usize], replacement: Value) { + let Some((&first, rest)) = path.split_first() else { + return; + }; if first >= nodes.len() { return; } - if path.len() == 1 { + if rest.is_empty() { nodes[first] = replacement; - } else { - let children = nodes[first]["children"] - .as_array_mut() - .map(|c| c as *mut Vec); - if let Some(children) = children { - // SAFETY: The raw pointer derives from an `&mut Vec` obtained - // via `as_array_mut()`. We follow a single path through the tree, - // never creating aliasing mutable references to the same Vec. - replace_at(unsafe { &mut *children }, &path[1..], replacement); - } + return; + } + if let Some(children) = children_mut(&mut nodes[first]) { + replace_at(children, rest, replacement); } } @@ -119,56 +113,46 @@ fn insert_at(nodes: &mut Vec, parent_path: &[usize], idx: usize, node: Va nodes.insert(safe_idx, node); return; } - let Some(&first) = parent_path.first() else { + let Some((&first, rest)) = parent_path.split_first() else { return; }; if first >= nodes.len() { return; } - let children = nodes[first]["children"] - .as_array_mut() - .map(|c| c as *mut Vec); - if let Some(children) = children { - // SAFETY: Same pattern as replace_at — single path through the tree, - // no aliasing mutable references. - insert_at(unsafe { &mut *children }, &parent_path[1..], idx, node); + if let Some(children) = children_mut(&mut nodes[first]) { + insert_at(children, rest, idx, node); } } fn remove_at(nodes: &mut Vec, path: &[usize]) { - let Some(&first) = path.first() else { return }; + let Some((&first, rest)) = path.split_first() else { + return; + }; if first >= nodes.len() { return; } - if path.len() == 1 { + if rest.is_empty() { nodes.remove(first); return; } - let children = nodes[first]["children"] - .as_array_mut() - .map(|c| c as *mut Vec); - if let Some(children) = children { - // SAFETY: Same pattern — single path, no aliasing mutable references. - remove_at(unsafe { &mut *children }, &path[1..]); + if let Some(children) = children_mut(&mut nodes[first]) { + remove_at(children, rest); } } -#[allow(clippy::ptr_arg)] -fn update_field_at(nodes: &mut Vec, path: &[usize], field: &str, value: Value) { - let Some(&first) = path.first() else { return }; +fn update_field_at(nodes: &mut [Value], path: &[usize], field: &str, value: Value) { + let Some((&first, rest)) = path.split_first() else { + return; + }; if first >= nodes.len() { return; } - if path.len() == 1 { + if rest.is_empty() { nodes[first][field] = value; return; } - let children = nodes[first]["children"] - .as_array_mut() - .map(|c| c as *mut Vec); - if let Some(children) = children { - // SAFETY: Same pattern — single path, no aliasing mutable references. - update_field_at(unsafe { &mut *children }, &path[1..], field, value); + if let Some(children) = children_mut(&mut nodes[first]) { + update_field_at(children, rest, field, value); } } @@ -221,4 +205,34 @@ mod tests { let v: serde_json::Value = serde_json::from_str(&result).unwrap(); assert_eq!(v["root"][0]["content"], "hello"); } + + fn nested_ir() -> &'static str { + r#"{"version":3,"root":[{"kind":"stack","children":[{"kind":"text","content":"inner"}]}]}"# + } + + #[test] + fn nested_replace_update_insert_remove() { + let muts = r#"[{"op":"updateText","path":[0,0],"content":"updated"}]"#; + let result = apply(nested_ir(), muts).unwrap(); + let v: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!(v["root"][0]["children"][0]["content"], "updated"); + + let muts = + r#"[{"op":"replaceNode","path":[0,0],"node":{"kind":"text","content":"replaced"}}]"#; + let result = apply(&result, muts).unwrap(); + let v: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!(v["root"][0]["children"][0]["content"], "replaced"); + + let muts = r#"[{"op":"insertNode","parentPath":[0],"index":1,"node":{"kind":"text","content":"second"}}]"#; + let result = apply(&result, muts).unwrap(); + let v: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!(v["root"][0]["children"].as_array().unwrap().len(), 2); + assert_eq!(v["root"][0]["children"][1]["content"], "second"); + + let muts = r#"[{"op":"removeNode","path":[0,0]}]"#; + let result = apply(&result, muts).unwrap(); + let v: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!(v["root"][0]["children"].as_array().unwrap().len(), 1); + assert_eq!(v["root"][0]["children"][0]["content"], "second"); + } } diff --git a/crates/aurorality-core/src/store.rs b/crates/aurorality-core/src/store.rs index 049935a..1bd77b7 100644 --- a/crates/aurorality-core/src/store.rs +++ b/crates/aurorality-core/src/store.rs @@ -57,7 +57,7 @@ pub fn store_path(bundle_id: String) -> String { /// Read one JSON value by key; returns `None` when missing. pub fn store_get(bundle_id: String, key: String) -> Result, AurorError> { - let _g = STORE_LOCK.lock().unwrap(); + let _g = STORE_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let path = application_support_file(bundle_id.trim()); let map = load_map(&path)?; Ok(map.get(&key).map(|v| v.to_string())) @@ -65,7 +65,7 @@ pub fn store_get(bundle_id: String, key: String) -> Result, Auror /// Upsert a JSON value (must parse as [`serde_json::Value`]). pub fn store_set(bundle_id: String, key: String, json: String) -> Result<(), AurorError> { - let _g = STORE_LOCK.lock().unwrap(); + let _g = STORE_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let path = application_support_file(bundle_id.trim()); let mut map = load_map(&path)?; let parsed: serde_json::Value = diff --git a/crates/aurorality-core/src/transport/matrix.rs b/crates/aurorality-core/src/transport/matrix.rs index 4d301e9..d1e305e 100644 --- a/crates/aurorality-core/src/transport/matrix.rs +++ b/crates/aurorality-core/src/transport/matrix.rs @@ -11,9 +11,7 @@ //! - `MATRIX_ROOM_ID` — target room ID (e.g. `!abc123:matrix.org`) use crate::bridge::NativePlugin; -use crate::transport::{ - envelope_err, envelope_ok, TransportHealth, TransportInfo, TransportMessage, -}; +use crate::transport::{TransportHealth, TransportInfo, TransportMessage}; use serde::Deserialize; use serde_json::Value; @@ -226,30 +224,23 @@ impl NativePlugin for MatrixClient { } "list" => { if !self.configured() { - return Ok(envelope_ok(serde_json::json!([]))); - } - match self.sync_messages() { - Ok(msgs) => Ok(envelope_ok(serde_json::to_value(&msgs).unwrap_or_default())), - Err(e) => Ok(envelope_err(&e)), + return Ok(serde_json::json!([])); } + self.sync_messages() + .map(|msgs| serde_json::to_value(&msgs).unwrap_or_default()) } "send" => { let text = payload.get("text").and_then(|v| v.as_str()).unwrap_or(""); if text.is_empty() { - return Ok(envelope_ok( - serde_json::json!({"accepted": false, "reason": "empty"}), - )); + return Ok(serde_json::json!({"accepted": false, "reason": "empty"})); } if !self.configured() { - return Ok(envelope_ok(serde_json::json!({ + return Ok(serde_json::json!({ "accepted": false, "reason": "matrix not configured" - }))); - } - match self.send_message(text) { - Ok(resp) => Ok(envelope_ok(resp)), - Err(e) => Ok(envelope_err(&e)), + })); } + self.send_message(text) } _ => Err(format!("unknown matrix method: {method}")), } diff --git a/crates/aurorality-core/src/transport/mod.rs b/crates/aurorality-core/src/transport/mod.rs index b20b7f9..4f5d47f 100644 --- a/crates/aurorality-core/src/transport/mod.rs +++ b/crates/aurorality-core/src/transport/mod.rs @@ -58,12 +58,3 @@ impl TransportHealth { } } } - -/// Helper: build a JSON envelope the plugin bridge expects. -pub fn envelope_ok(data: serde_json::Value) -> serde_json::Value { - serde_json::json!({ "ok": true, "data": data }) -} - -pub fn envelope_err(msg: &str) -> serde_json::Value { - serde_json::json!({ "ok": false, "error": msg }) -} diff --git a/crates/aurorality-core/src/transport/stalwart.rs b/crates/aurorality-core/src/transport/stalwart.rs index fecbff1..8fd99cb 100644 --- a/crates/aurorality-core/src/transport/stalwart.rs +++ b/crates/aurorality-core/src/transport/stalwart.rs @@ -14,9 +14,7 @@ //! If credentials are missing the adapter reports `connected: false` but won't error. use crate::bridge::NativePlugin; -use crate::transport::{ - envelope_err, envelope_ok, TransportHealth, TransportInfo, TransportMessage, -}; +use crate::transport::{TransportHealth, TransportInfo, TransportMessage}; use serde_json::Value; @@ -270,14 +268,12 @@ impl StalwartClient { fn handle_list(&mut self) -> Result { match self.list_messages() { - Ok(messages) => Ok(envelope_ok( - serde_json::to_value(&messages).unwrap_or_default(), - )), + Ok(messages) => Ok(serde_json::to_value(&messages).unwrap_or_default()), Err(e) => { if !self.configured() { - Ok(envelope_ok(serde_json::json!([]))) + Ok(serde_json::json!([])) } else { - Ok(envelope_err(&e)) + Err(e) } } } @@ -286,20 +282,15 @@ impl StalwartClient { fn handle_send(&mut self, payload: &Value) -> Result { let text = payload.get("text").and_then(|v| v.as_str()).unwrap_or(""); if text.is_empty() { - return Ok(envelope_ok( - serde_json::json!({"accepted": false, "reason": "empty"}), - )); + return Ok(serde_json::json!({"accepted": false, "reason": "empty"})); } if !self.configured() { - return Ok(envelope_ok(serde_json::json!({ + return Ok(serde_json::json!({ "accepted": false, "reason": "stalwart not configured" - }))); - } - match self.archive_message(text) { - Ok(resp) => Ok(envelope_ok(resp)), - Err(e) => Ok(envelope_err(&e)), + })); } + self.archive_message(text) } } diff --git a/crates/aurorality-core/tests/stalwart_integration.rs b/crates/aurorality-core/tests/stalwart_integration.rs index 32e84a5..e9ceaef 100644 --- a/crates/aurorality-core/tests/stalwart_integration.rs +++ b/crates/aurorality-core/tests/stalwart_integration.rs @@ -57,11 +57,13 @@ mod stalwart_integration { .expect("STALWART_USERNAME and STALWART_PASSWORD must be set for integration tests"); let result = client.invoke("list", &serde_json::json!({})).unwrap(); - let envelope: Value = serde_json::from_value(result).unwrap(); + let messages: Value = serde_json::from_value(result).unwrap(); - // Should be a valid JSON-RPC-style response - assert_eq!(envelope["ok"], true, "list should succeed: {envelope:?}"); - assert!(envelope["data"].is_array(), "data should be an array"); + // NativePlugin::invoke returns the payload; plugin_invoke wraps `{ ok, data }`. + assert!( + messages.is_array(), + "list should return a message array: {messages:?}" + ); } #[test]