1mod ctx_fns;
6mod emitter_fns;
7
8fn truncate_utf8(s: &str, max_bytes: usize) -> &str {
10 if s.len() <= max_bytes {
11 return s;
12 }
13 let mut end = max_bytes;
14 while end > 0 && !s.is_char_boundary(end) {
15 end -= 1;
16 }
17 &s[..end]
18}
19
20use crate::error::LuaError;
21use crate::lua_env::LuaEnv;
22use crate::types::{
23 parse_event_category, parse_signal_response, LuaRequest, LuaResponse, LuaSignal,
24};
25use mlua::{Function, IntoLua, Lua, LuaSerdeExt, RegistryKey, Table, Value as LuaValue};
26use orcs_component::{
27 ChildContext, Component, ComponentError, ComponentLoader, ComponentSnapshot, Emitter,
28 EventCategory, RuntimeHints, SnapshotError, SpawnError, Status, SubscriptionEntry,
29};
30use orcs_event::{Request, Signal, SignalResponse};
31use orcs_runtime::sandbox::SandboxPolicy;
32use orcs_types::ComponentId;
33use parking_lot::Mutex;
34use serde_json::Value as JsonValue;
35use std::path::Path;
36use std::sync::Arc;
37
38fn extract_suspended(err: &mlua::Error) -> Option<ComponentError> {
44 match err {
45 mlua::Error::ExternalError(ext) => ext
46 .downcast_ref::<ComponentError>()
47 .filter(|ce| matches!(ce, ComponentError::Suspended { .. }))
48 .cloned(),
49 mlua::Error::CallbackError { cause, .. } => extract_suspended(cause),
50 _ => None,
51 }
52}
53
54pub struct LuaComponent {
83 lua: Mutex<Lua>,
85 id: ComponentId,
87 subscriptions: Vec<EventCategory>,
89 subscription_entries: Vec<SubscriptionEntry>,
91 status: Status,
93 on_request_key: RegistryKey,
95 on_signal_key: RegistryKey,
97 init_key: Option<RegistryKey>,
99 shutdown_key: Option<RegistryKey>,
101 snapshot_key: Option<RegistryKey>,
103 restore_key: Option<RegistryKey>,
105 script_path: Option<String>,
107 emitter: Option<Arc<Mutex<Box<dyn Emitter>>>>,
112 child_context: Option<Arc<Mutex<Box<dyn ChildContext>>>>,
117 sandbox: Arc<dyn SandboxPolicy>,
122 hints: RuntimeHints,
124}
125
126#[allow(unsafe_code)]
149unsafe impl Send for LuaComponent {}
150#[allow(unsafe_code)]
151unsafe impl Sync for LuaComponent {}
152
153impl LuaComponent {
154 pub fn from_file<P: AsRef<Path>>(
168 path: P,
169 sandbox: Arc<dyn SandboxPolicy>,
170 ) -> Result<Self, LuaError> {
171 let path = path.as_ref();
172 let script = std::fs::read_to_string(path)
173 .map_err(|_| LuaError::ScriptNotFound(path.display().to_string()))?;
174
175 let script_dir = path.parent().map(|p| p.to_path_buf());
176 let mut component = Self::from_script_inner(&script, sandbox, script_dir.as_deref(), None)?;
177 component.script_path = Some(path.display().to_string());
178 Ok(component)
179 }
180
181 pub fn from_dir<P: AsRef<Path>>(
201 dir: P,
202 sandbox: Arc<dyn SandboxPolicy>,
203 ) -> Result<Self, LuaError> {
204 let dir = dir.as_ref();
205 let init_path = dir.join("init.lua");
206 let script = std::fs::read_to_string(&init_path)
207 .map_err(|_| LuaError::ScriptNotFound(init_path.display().to_string()))?;
208
209 let mut component = Self::from_script_inner(&script, sandbox, Some(dir), None)?;
210 component.script_path = Some(init_path.display().to_string());
211 Ok(component)
212 }
213
214 pub fn from_script(script: &str, sandbox: Arc<dyn SandboxPolicy>) -> Result<Self, LuaError> {
225 Self::from_script_inner(script, sandbox, None, None)
226 }
227
228 pub fn from_script_with_globals(
242 script: &str,
243 sandbox: Arc<dyn SandboxPolicy>,
244 globals: Option<&serde_json::Map<String, serde_json::Value>>,
245 ) -> Result<Self, LuaError> {
246 Self::from_script_inner(script, sandbox, None, globals)
247 }
248
249 fn from_script_inner(
256 script: &str,
257 sandbox: Arc<dyn SandboxPolicy>,
258 script_dir: Option<&Path>,
259 globals: Option<&serde_json::Map<String, serde_json::Value>>,
260 ) -> Result<Self, LuaError> {
261 let mut lua_env = LuaEnv::new(Arc::clone(&sandbox));
263 if let Some(dir) = script_dir {
264 lua_env = lua_env.with_search_path(dir);
265 }
266
267 let lua = lua_env.create_lua()?;
269
270 {
273 let orcs_table: Table = lua.globals().get("orcs")?;
274 let output_noop = lua.create_function(|_, msg: String| {
275 tracing::warn!(
276 "[lua] orcs.output called without emitter (noop): {}",
277 truncate_utf8(&msg, 100)
278 );
279 Ok(())
280 })?;
281 orcs_table.set("output", output_noop)?;
282
283 let output_level_noop = lua.create_function(|_, (msg, _level): (String, String)| {
284 tracing::warn!(
285 "[lua] orcs.output_with_level called without emitter (noop): {}",
286 truncate_utf8(&msg, 100)
287 );
288 Ok(())
289 })?;
290 orcs_table.set("output_with_level", output_level_noop)?;
291 }
292
293 if let Some(map) = globals {
298 let lua_globals = lua.globals();
299 for (k, v) in map {
300 let lua_val = json_to_lua_value(&lua, v)?;
301 lua_globals.set(k.as_str(), lua_val)?;
302 }
303 }
304
305 let component_table: Table = lua
307 .load(script)
308 .eval()
309 .map_err(|e| LuaError::InvalidScript(e.to_string()))?;
310
311 let id_str: String = component_table
313 .get("id")
314 .map_err(|_| LuaError::MissingCallback("id".to_string()))?;
315 let namespace: String = component_table
316 .get("namespace")
317 .unwrap_or_else(|_| "lua".to_string());
318 let id = ComponentId::new(namespace, &id_str);
319
320 let subs_table: Table = component_table
331 .get("subscriptions")
332 .map_err(|_| LuaError::MissingCallback("subscriptions".to_string()))?;
333
334 let mut subscriptions = Vec::new();
335 let mut subscription_entries = Vec::new();
336 for pair in subs_table.pairs::<i64, LuaValue>() {
337 let (_, value) = pair.map_err(|e| LuaError::TypeError(e.to_string()))?;
338 match &value {
339 LuaValue::String(s) => {
340 let cat_str = s.to_str().map_err(|e| LuaError::TypeError(e.to_string()))?;
341 if let Some(cat) = parse_event_category(&cat_str) {
342 subscriptions.push(cat.clone());
343 subscription_entries.push(SubscriptionEntry::all(cat));
344 }
345 }
346 LuaValue::Table(tbl) => {
347 let cat_str: String = tbl.get("category").map_err(|e| {
349 LuaError::TypeError(format!(
350 "subscription table must have 'category' field: {e}"
351 ))
352 })?;
353 if let Some(cat) = parse_event_category(&cat_str) {
354 subscriptions.push(cat.clone());
355 let ops_table: Option<Table> = tbl.get("operations").ok();
357 if let Some(ops) = ops_table {
358 let mut op_names = Vec::new();
359 for (_, op) in ops.pairs::<i64, String>().flatten() {
360 op_names.push(op);
361 }
362 subscription_entries
363 .push(SubscriptionEntry::with_operations(cat, op_names));
364 } else {
365 subscription_entries.push(SubscriptionEntry::all(cat));
366 }
367 }
368 }
369 _ => {
370 tracing::warn!("subscription entry must be a string or table, ignoring");
371 }
372 }
373 }
374
375 let on_request_fn: Function = component_table
377 .get("on_request")
378 .map_err(|_| LuaError::MissingCallback("on_request".to_string()))?;
379
380 let on_signal_fn: Function = component_table
381 .get("on_signal")
382 .map_err(|_| LuaError::MissingCallback("on_signal".to_string()))?;
383
384 let on_request_key = lua.create_registry_value(on_request_fn)?;
386 let on_signal_key = lua.create_registry_value(on_signal_fn)?;
387
388 let init_key = component_table
390 .get::<Function>("init")
391 .ok()
392 .map(|f| lua.create_registry_value(f))
393 .transpose()?;
394
395 let shutdown_key = component_table
396 .get::<Function>("shutdown")
397 .ok()
398 .map(|f| lua.create_registry_value(f))
399 .transpose()?;
400
401 let snapshot_key = component_table
402 .get::<Function>("snapshot")
403 .ok()
404 .map(|f| lua.create_registry_value(f))
405 .transpose()?;
406
407 let restore_key = component_table
408 .get::<Function>("restore")
409 .ok()
410 .map(|f| lua.create_registry_value(f))
411 .transpose()?;
412
413 let hints = RuntimeHints {
415 output_to_io: component_table.get("output_to_io").unwrap_or(false),
416 elevated: component_table.get("elevated").unwrap_or(false),
417 child_spawner: component_table.get("child_spawner").unwrap_or(false),
418 };
419
420 Ok(Self {
421 lua: Mutex::new(lua),
422 id,
423 subscriptions,
424 subscription_entries,
425 status: Status::Idle,
426 on_request_key,
427 on_signal_key,
428 init_key,
429 shutdown_key,
430 snapshot_key,
431 restore_key,
432 script_path: None,
433 emitter: None,
434 child_context: None,
435 sandbox,
436 hints,
437 })
438 }
439
440 #[cfg(any(test, feature = "test-utils"))]
444 pub(crate) fn with_lua<F, R>(&self, f: F) -> R
445 where
446 F: FnOnce(&Lua) -> R,
447 {
448 let lua = self.lua.lock();
449 f(&lua)
450 }
451
452 #[must_use]
454 pub fn script_path(&self) -> Option<&str> {
455 self.script_path.as_deref()
456 }
457
458 pub fn reload(&mut self) -> Result<(), LuaError> {
464 let Some(path) = &self.script_path else {
465 return Err(LuaError::InvalidScript("no script path".into()));
466 };
467
468 let new_component = Self::from_file(path, Arc::clone(&self.sandbox))?;
469
470 self.lua = new_component.lua;
472 self.subscriptions = new_component.subscriptions;
473 self.on_request_key = new_component.on_request_key;
474 self.on_signal_key = new_component.on_signal_key;
475 self.init_key = new_component.init_key;
476 self.shutdown_key = new_component.shutdown_key;
477 self.snapshot_key = new_component.snapshot_key;
478 self.restore_key = new_component.restore_key;
479 if let Some(emitter) = &self.emitter {
483 let lua = self.lua.lock();
484 emitter_fns::register(&lua, Arc::clone(emitter))?;
485 }
486
487 if let Some(ctx) = &self.child_context {
489 let lua = self.lua.lock();
490 ctx_fns::register(&lua, Arc::clone(ctx), Arc::clone(&self.sandbox))?;
491 }
492
493 tracing::info!("Reloaded Lua component: {}", self.id);
494 Ok(())
495 }
496
497 #[must_use]
501 pub fn has_emitter(&self) -> bool {
502 self.emitter.is_some()
503 }
504
505 #[must_use]
509 pub fn has_child_context(&self) -> bool {
510 self.child_context.is_some()
511 }
512
513 pub fn set_child_context(&mut self, ctx: Box<dyn ChildContext>) {
524 self.install_child_context(ctx);
525 }
526
527 fn install_child_context(&mut self, ctx: Box<dyn ChildContext>) {
532 let hook_registry = ctx
533 .extension("hook_registry")
534 .and_then(|any| any.downcast::<orcs_hook::SharedHookRegistry>().ok())
535 .map(|boxed| *boxed);
536
537 let mcp_manager = ctx
538 .extension("mcp_manager")
539 .and_then(|any| {
540 any.downcast::<std::sync::Arc<orcs_mcp::McpClientManager>>()
541 .ok()
542 })
543 .map(|boxed| *boxed);
544
545 let ctx_arc = Arc::new(Mutex::new(ctx));
546 self.child_context = Some(Arc::clone(&ctx_arc));
547
548 let lua = self.lua.lock();
549
550 if let Err(e) = ctx_fns::register(&lua, ctx_arc, Arc::clone(&self.sandbox)) {
551 tracing::warn!("Failed to register child context functions: {}", e);
552 }
553
554 if let Some(manager) = mcp_manager {
558 lua.set_app_data(crate::tool_registry::SharedMcpManager(
560 std::sync::Arc::clone(&manager),
561 ));
562
563 if let Ok(handle) = tokio::runtime::Handle::try_current() {
570 let defs = tokio::task::block_in_place(|| handle.block_on(manager.intent_defs()));
571
572 if !defs.is_empty() {
573 if let Some(mut registry) =
574 lua.app_data_mut::<crate::tool_registry::IntentRegistry>()
575 {
576 for def in &defs {
577 if let Err(e) = registry.register(def.clone()) {
578 tracing::warn!(
579 intent = %def.name,
580 error = %e,
581 "Failed to register MCP intent"
582 );
583 }
584 }
585 } else {
586 lua.set_app_data(crate::tool_registry::PendingMcpDefs(defs));
588 }
589 tracing::info!(
590 component = %self.id.fqn(),
591 "MCP client manager wired"
592 );
593 }
594 }
595 }
596
597 let Some(registry) = hook_registry else {
599 return;
600 };
601
602 if let Err(e) =
603 crate::hook_helpers::register_hook_function(&lua, registry.clone(), self.id.clone())
604 {
605 tracing::warn!("Failed to register orcs.hook(): {}", e);
606 } else {
607 tracing::debug!(component = %self.id.fqn(), "orcs.hook() registered");
608 }
609
610 if let Err(e) = crate::hook_helpers::register_unhook_function(&lua, registry.clone()) {
611 tracing::warn!("Failed to register orcs.unhook(): {}", e);
612 }
613
614 lua.set_app_data(crate::tools::ToolHookContext {
615 registry,
616 component_id: self.id.clone(),
617 });
618 if let Err(e) = crate::tools::wrap_tools_with_hooks(&lua) {
619 tracing::warn!("Failed to wrap tools with hooks: {}", e);
620 }
621 }
622}
623
624impl Component for LuaComponent {
625 fn id(&self) -> &ComponentId {
626 &self.id
627 }
628
629 fn subscriptions(&self) -> &[EventCategory] {
630 &self.subscriptions
631 }
632
633 fn subscription_entries(&self) -> Vec<SubscriptionEntry> {
634 self.subscription_entries.clone()
635 }
636
637 fn runtime_hints(&self) -> RuntimeHints {
638 self.hints.clone()
639 }
640
641 fn status(&self) -> Status {
642 self.status
643 }
644
645 #[tracing::instrument(
646 skip(self, request),
647 fields(component = %self.id.fqn(), operation = %request.operation)
648 )]
649 fn on_request(&mut self, request: &Request) -> Result<JsonValue, ComponentError> {
650 if self.status == Status::Aborted {
651 return Err(ComponentError::ExecutionFailed(
652 "component is aborted".to_string(),
653 ));
654 }
655 self.status = Status::Running;
656
657 let lua = self.lua.lock();
658
659 let on_request: Function = lua.registry_value(&self.on_request_key).map_err(|e| {
661 tracing::debug!("Failed to get on_request from registry: {}", e);
662 ComponentError::ExecutionFailed("lua callback not found".to_string())
663 })?;
664
665 let lua_req = LuaRequest::from_request(request);
667
668 let result: LuaResponse = on_request.call(lua_req).map_err(|e| {
670 if let Some(suspended) = extract_suspended(&e) {
673 return suspended;
674 }
675 tracing::debug!("Lua on_request error: {}", e);
677 ComponentError::ExecutionFailed("lua script execution failed".to_string())
678 })?;
679
680 drop(lua);
681 self.status = Status::Idle;
682
683 if result.success {
684 Ok(result.data.unwrap_or(JsonValue::Null))
685 } else {
686 Err(ComponentError::ExecutionFailed(
687 result.error.unwrap_or_else(|| "unknown error".into()),
688 ))
689 }
690 }
691
692 #[tracing::instrument(
693 skip(self, signal),
694 fields(component = %self.id.fqn(), signal_kind = ?signal.kind)
695 )]
696 fn on_signal(&mut self, signal: &Signal) -> SignalResponse {
697 let lua = self.lua.lock();
698
699 let Ok(on_signal): Result<Function, _> = lua.registry_value(&self.on_signal_key) else {
700 return SignalResponse::Ignored;
701 };
702
703 let lua_sig = LuaSignal::from_signal(signal);
704
705 let result: Result<String, _> = on_signal.call(lua_sig);
706
707 match result {
708 Ok(response_str) => {
709 let response = parse_signal_response(&response_str);
710 if matches!(response, SignalResponse::Abort) {
711 drop(lua);
712 self.status = Status::Aborted;
713 }
714 response
715 }
716 Err(e) => {
717 tracing::warn!("Lua on_signal error: {}", e);
718 SignalResponse::Ignored
719 }
720 }
721 }
722
723 fn abort(&mut self) {
724 self.status = Status::Aborted;
725 }
726
727 #[tracing::instrument(skip(self, config), fields(component = %self.id.fqn()))]
733 fn init(&mut self, config: &serde_json::Value) -> Result<(), ComponentError> {
734 let Some(init_key) = &self.init_key else {
735 return Ok(());
736 };
737
738 let lua = self.lua.lock();
739
740 let init_fn: Function = lua.registry_value(init_key).map_err(|e| {
741 tracing::debug!("Failed to get init from registry: {}", e);
742 ComponentError::ExecutionFailed("lua init callback not found".to_string())
743 })?;
744
745 let lua_config = if config.is_null()
747 || (config.is_object() && config.as_object().map_or(true, serde_json::Map::is_empty))
748 {
749 mlua::Value::Nil
750 } else {
751 lua.to_value(config).map_err(|e| {
752 tracing::debug!("Failed to convert config to Lua: {}", e);
753 ComponentError::ExecutionFailed("config conversion failed".to_string())
754 })?
755 };
756
757 init_fn.call::<()>(lua_config).map_err(|e| {
758 tracing::debug!("Lua init error: {}", e);
759 ComponentError::ExecutionFailed("lua init callback failed".to_string())
760 })?;
761
762 Ok(())
763 }
764
765 #[tracing::instrument(skip(self), fields(component = %self.id.fqn()))]
766 fn shutdown(&mut self) {
767 let Some(shutdown_key) = &self.shutdown_key else {
768 return;
769 };
770
771 let lua = self.lua.lock();
772
773 if let Ok(shutdown_fn) = lua.registry_value::<Function>(shutdown_key) {
774 if let Err(e) = shutdown_fn.call::<()>(()) {
775 tracing::warn!("Lua shutdown error: {}", e);
776 }
777 }
778 }
779
780 fn snapshot(&self) -> Result<ComponentSnapshot, SnapshotError> {
781 let Some(snapshot_key) = &self.snapshot_key else {
782 return Err(SnapshotError::NotSupported(self.id.fqn()));
783 };
784
785 let lua = self.lua.lock();
786
787 let snapshot_fn: Function = lua
788 .registry_value(snapshot_key)
789 .map_err(|e| SnapshotError::InvalidData(format!("snapshot callback not found: {e}")))?;
790
791 let lua_result: LuaValue = snapshot_fn
792 .call(())
793 .map_err(|e| SnapshotError::InvalidData(format!("snapshot callback failed: {e}")))?;
794
795 let json_value = lua_value_to_json(&lua_result);
796 ComponentSnapshot::from_state(self.id.fqn(), &json_value)
797 }
798
799 fn restore(&mut self, snapshot: &ComponentSnapshot) -> Result<(), SnapshotError> {
800 let Some(restore_key) = &self.restore_key else {
801 return Err(SnapshotError::NotSupported(self.id.fqn()));
802 };
803
804 snapshot.validate(&self.id.fqn())?;
805
806 let lua = self.lua.lock();
807
808 let restore_fn: Function = lua
809 .registry_value(restore_key)
810 .map_err(|e| SnapshotError::InvalidData(format!("restore callback not found: {e}")))?;
811
812 let lua_value = json_to_lua_value(&lua, &snapshot.state).map_err(|e| {
813 SnapshotError::InvalidData(format!("failed to convert snapshot to lua: {e}"))
814 })?;
815
816 restore_fn
817 .call::<()>(lua_value)
818 .map_err(|e| SnapshotError::RestoreFailed {
819 component: self.id.fqn(),
820 reason: format!("restore callback failed: {e}"),
821 })?;
822
823 Ok(())
824 }
825
826 fn set_emitter(&mut self, emitter: Box<dyn Emitter>) {
827 let emitter_arc = Arc::new(Mutex::new(emitter));
828 self.emitter = Some(Arc::clone(&emitter_arc));
829
830 let lua = self.lua.lock();
832 if let Err(e) = emitter_fns::register(&lua, emitter_arc) {
833 tracing::warn!("Failed to register emitter functions: {}", e);
834 }
835 }
836
837 fn set_child_context(&mut self, ctx: Box<dyn ChildContext>) {
838 self.install_child_context(ctx);
839 }
840}
841
842#[derive(Clone)]
848pub struct LuaComponentLoader {
849 sandbox: Arc<dyn SandboxPolicy>,
850 builtins: Option<Arc<std::collections::HashMap<String, String>>>,
851}
852
853impl LuaComponentLoader {
854 #[must_use]
856 pub fn new(sandbox: Arc<dyn SandboxPolicy>) -> Self {
857 Self {
858 sandbox,
859 builtins: None,
860 }
861 }
862
863 #[must_use]
869 pub fn with_builtins(
870 mut self,
871 builtins: Arc<std::collections::HashMap<String, String>>,
872 ) -> Self {
873 self.builtins = Some(builtins);
874 self
875 }
876}
877
878impl ComponentLoader for LuaComponentLoader {
879 fn load_from_script(
880 &self,
881 script: &str,
882 _id: Option<&str>,
883 globals: Option<&serde_json::Map<String, serde_json::Value>>,
884 ) -> Result<Box<dyn Component>, SpawnError> {
885 LuaComponent::from_script_with_globals(script, Arc::clone(&self.sandbox), globals)
887 .map(|c| Box::new(c) as Box<dyn Component>)
888 .map_err(|e| SpawnError::InvalidScript(e.to_string()))
889 }
890
891 fn resolve_builtin(&self, name: &str) -> Option<String> {
892 self.builtins.as_ref()?.get(name).cloned()
895 }
896}
897
898fn lua_value_to_json(value: &LuaValue) -> JsonValue {
902 match value {
903 LuaValue::Nil => JsonValue::Null,
904 LuaValue::Boolean(b) => JsonValue::Bool(*b),
905 LuaValue::Integer(i) => JsonValue::Number((*i).into()),
906 LuaValue::Number(n) => serde_json::Number::from_f64(*n)
907 .map(JsonValue::Number)
908 .unwrap_or(JsonValue::Null),
909 LuaValue::String(s) => JsonValue::String(s.to_string_lossy().to_string()),
910 LuaValue::Table(table) => {
911 let len = table.raw_len();
913 let is_array = len > 0
914 && table
915 .clone()
916 .pairs::<i64, LuaValue>()
917 .enumerate()
918 .all(|(idx, pair)| pair.map(|(k, _)| k == (idx as i64 + 1)).unwrap_or(false));
919
920 if is_array {
921 let arr: Vec<JsonValue> = table
922 .clone()
923 .sequence_values::<LuaValue>()
924 .filter_map(|v| v.ok())
925 .map(|v| lua_value_to_json(&v))
926 .collect();
927 JsonValue::Array(arr)
928 } else {
929 let mut map = serde_json::Map::new();
930 if let Ok(pairs) = table
931 .clone()
932 .pairs::<LuaValue, LuaValue>()
933 .collect::<Result<Vec<_>, _>>()
934 {
935 for (k, v) in pairs {
936 let key = match &k {
937 LuaValue::String(s) => s.to_string_lossy().to_string(),
938 LuaValue::Integer(i) => i.to_string(),
939 _ => continue,
940 };
941 map.insert(key, lua_value_to_json(&v));
942 }
943 }
944 JsonValue::Object(map)
945 }
946 }
947 _ => JsonValue::Null,
948 }
949}
950
951fn json_to_lua_value(lua: &Lua, value: &JsonValue) -> Result<LuaValue, mlua::Error> {
953 match value {
954 JsonValue::Null => Ok(LuaValue::Nil),
955 JsonValue::Bool(b) => Ok(LuaValue::Boolean(*b)),
956 JsonValue::Number(n) => {
957 if let Some(i) = n.as_i64() {
958 Ok(LuaValue::Integer(i))
959 } else if let Some(f) = n.as_f64() {
960 Ok(LuaValue::Number(f))
961 } else {
962 Ok(LuaValue::Nil)
963 }
964 }
965 JsonValue::String(s) => s.as_str().into_lua(lua),
966 JsonValue::Array(arr) => {
967 let table = lua.create_table()?;
968 for (i, v) in arr.iter().enumerate() {
969 let lua_val = json_to_lua_value(lua, v)?;
970 table.raw_set(i + 1, lua_val)?;
971 }
972 Ok(LuaValue::Table(table))
973 }
974 JsonValue::Object(map) => {
975 let table = lua.create_table()?;
976 for (k, v) in map {
977 let lua_val = json_to_lua_value(lua, v)?;
978 table.raw_set(k.as_str(), lua_val)?;
979 }
980 Ok(LuaValue::Table(table))
981 }
982 }
983}
984
985#[cfg(test)]
986mod tests;
987
988#[cfg(test)]
989mod extract_suspended_tests {
990 use super::*;
991
992 #[test]
993 fn extracts_suspended_from_external_error() {
994 let suspended = ComponentError::Suspended {
995 approval_id: "ap-1".into(),
996 grant_pattern: "shell:*".into(),
997 pending_request: serde_json::json!({"cmd": "ls"}),
998 };
999 let err = mlua::Error::ExternalError(Arc::new(suspended));
1000 let result = extract_suspended(&err);
1001 assert!(
1002 result.is_some(),
1003 "should extract Suspended from ExternalError"
1004 );
1005 match result.expect("already checked is_some") {
1006 ComponentError::Suspended { approval_id, .. } => {
1007 assert_eq!(approval_id, "ap-1");
1008 }
1009 other => panic!("Expected Suspended, got {:?}", other),
1010 }
1011 }
1012
1013 #[test]
1014 fn extracts_suspended_from_callback_error() {
1015 let suspended = ComponentError::Suspended {
1016 approval_id: "ap-2".into(),
1017 grant_pattern: "tool:*".into(),
1018 pending_request: serde_json::Value::Null,
1019 };
1020 let inner = mlua::Error::ExternalError(Arc::new(suspended));
1021 let err = mlua::Error::CallbackError {
1022 traceback: "stack trace".into(),
1023 cause: Arc::new(inner),
1024 };
1025 let result = extract_suspended(&err);
1026 assert!(
1027 result.is_some(),
1028 "should extract Suspended through CallbackError"
1029 );
1030 }
1031
1032 #[test]
1033 fn extracts_suspended_from_nested_callback_errors() {
1034 let suspended = ComponentError::Suspended {
1035 approval_id: "ap-3".into(),
1036 grant_pattern: "exec:*".into(),
1037 pending_request: serde_json::Value::Null,
1038 };
1039 let inner = mlua::Error::ExternalError(Arc::new(suspended));
1040 let mid = mlua::Error::CallbackError {
1041 traceback: "level 1".into(),
1042 cause: Arc::new(inner),
1043 };
1044 let outer = mlua::Error::CallbackError {
1045 traceback: "level 2".into(),
1046 cause: Arc::new(mid),
1047 };
1048 let result = extract_suspended(&outer);
1049 assert!(
1050 result.is_some(),
1051 "should extract through nested CallbackErrors"
1052 );
1053 }
1054
1055 #[test]
1056 fn returns_none_for_non_suspended_component_error() {
1057 let err =
1058 mlua::Error::ExternalError(Arc::new(ComponentError::ExecutionFailed("timeout".into())));
1059 assert!(
1060 extract_suspended(&err).is_none(),
1061 "ExecutionFailed should not match"
1062 );
1063 }
1064
1065 #[test]
1066 fn returns_none_for_runtime_error() {
1067 let err = mlua::Error::RuntimeError("some error".into());
1068 assert!(
1069 extract_suspended(&err).is_none(),
1070 "RuntimeError should not match"
1071 );
1072 }
1073}