1use std::collections::HashMap;
23
24use crate::error::LuaError;
25use crate::types::serde_json_to_lua;
26use mlua::{Lua, Table};
27use orcs_types::intent::{IntentDef, IntentResolver};
28
29pub struct IntentRegistry {
39 defs: Vec<IntentDef>,
40 index: HashMap<String, usize>,
42}
43
44impl Default for IntentRegistry {
45 fn default() -> Self {
46 Self::new()
47 }
48}
49
50impl IntentRegistry {
51 pub fn new() -> Self {
53 let defs = builtin_intent_defs();
54 let index = defs
55 .iter()
56 .enumerate()
57 .map(|(i, d)| (d.name.clone(), i))
58 .collect();
59 Self { defs, index }
60 }
61
62 pub fn get(&self, name: &str) -> Option<&IntentDef> {
64 self.index.get(name).map(|&i| &self.defs[i])
65 }
66
67 pub fn register(&mut self, def: IntentDef) -> Result<(), String> {
69 if self.index.contains_key(&def.name) {
70 return Err(format!("intent already registered: {}", def.name));
71 }
72 let idx = self.defs.len();
73 self.index.insert(def.name.clone(), idx);
74 self.defs.push(def);
75 Ok(())
76 }
77
78 pub fn all(&self) -> &[IntentDef] {
80 &self.defs
81 }
82
83 pub fn len(&self) -> usize {
85 self.defs.len()
86 }
87
88 pub fn is_empty(&self) -> bool {
90 self.defs.is_empty()
91 }
92}
93
94fn json_schema(properties: &[(&str, &str, bool)]) -> serde_json::Value {
98 let mut props = serde_json::Map::new();
99 let mut required = Vec::new();
100
101 for &(name, description, is_required) in properties {
102 props.insert(
103 name.to_string(),
104 serde_json::json!({
105 "type": "string",
106 "description": description,
107 }),
108 );
109 if is_required {
110 required.push(serde_json::Value::String(name.to_string()));
111 }
112 }
113
114 serde_json::json!({
115 "type": "object",
116 "properties": props,
117 "required": required,
118 })
119}
120
121fn builtin_intent_defs() -> Vec<IntentDef> {
123 vec![
124 IntentDef {
125 name: "read".into(),
126 description: "Read file contents. Path relative to project root.".into(),
127 parameters: json_schema(&[("path", "File path to read", true)]),
128 resolver: IntentResolver::Internal,
129 },
130 IntentDef {
131 name: "write".into(),
132 description: "Write file contents (atomic). Creates parent dirs.".into(),
133 parameters: json_schema(&[
134 ("path", "File path to write", true),
135 ("content", "Content to write", true),
136 ]),
137 resolver: IntentResolver::Internal,
138 },
139 IntentDef {
140 name: "grep".into(),
141 description: "Search with regex. Path can be file or directory (recursive).".into(),
142 parameters: json_schema(&[
143 ("pattern", "Regex pattern to search for", true),
144 ("path", "File or directory to search in", true),
145 ]),
146 resolver: IntentResolver::Internal,
147 },
148 IntentDef {
149 name: "glob".into(),
150 description: "Find files by glob pattern. Dir defaults to project root.".into(),
151 parameters: json_schema(&[
152 ("pattern", "Glob pattern (e.g. '**/*.rs')", true),
153 ("dir", "Base directory (defaults to project root)", false),
154 ]),
155 resolver: IntentResolver::Internal,
156 },
157 IntentDef {
158 name: "mkdir".into(),
159 description: "Create directory (with parents).".into(),
160 parameters: json_schema(&[("path", "Directory path to create", true)]),
161 resolver: IntentResolver::Internal,
162 },
163 IntentDef {
164 name: "remove".into(),
165 description: "Remove file or directory.".into(),
166 parameters: json_schema(&[("path", "Path to remove", true)]),
167 resolver: IntentResolver::Internal,
168 },
169 IntentDef {
170 name: "mv".into(),
171 description: "Move / rename file or directory.".into(),
172 parameters: json_schema(&[
173 ("src", "Source path", true),
174 ("dst", "Destination path", true),
175 ]),
176 resolver: IntentResolver::Internal,
177 },
178 IntentDef {
179 name: "exec".into(),
180 description: "Execute shell command. cwd = project root.".into(),
181 parameters: json_schema(&[("cmd", "Shell command to execute", true)]),
182 resolver: IntentResolver::Internal,
183 },
184 ]
185}
186
187fn dispatch_tool(lua: &Lua, name: &str, args: &Table) -> mlua::Result<Table> {
195 let resolver = {
196 let registry = ensure_registry(lua)?;
197 match registry.get(name) {
198 Some(def) => def.resolver.clone(),
199 None => {
200 let result = lua.create_table()?;
201 set_error(&result, &format!("unknown intent: {name}"))?;
202 return Ok(result);
203 }
204 }
205 };
206
207 let start = std::time::Instant::now();
208 let result = match resolver {
209 IntentResolver::Internal => dispatch_internal(lua, name, args),
210 IntentResolver::Component {
211 component_fqn,
212 operation,
213 } => dispatch_component(lua, name, &component_fqn, &operation, args),
214 };
215 let duration_ms = start.elapsed().as_millis() as u64;
216 let ok = result
217 .as_ref()
218 .map(|t| t.get::<bool>("ok").unwrap_or(false))
219 .unwrap_or(false);
220 tracing::info!(
221 "intent dispatch: {name} → {ok} ({duration_ms}ms)",
222 ok = if ok { "ok" } else { "err" }
223 );
224 result
225}
226
227fn dispatch_internal(lua: &Lua, name: &str, args: &Table) -> mlua::Result<Table> {
233 let orcs: Table = lua.globals().get("orcs")?;
234
235 match name {
236 "read" => {
237 let path: String = get_required_arg(args, "path")?;
238 let f: mlua::Function = orcs.get("read")?;
239 f.call(path)
240 }
241 "write" => {
242 let path: String = get_required_arg(args, "path")?;
243 let content: String = get_required_arg(args, "content")?;
244 let f: mlua::Function = orcs.get("write")?;
245 f.call((path, content))
246 }
247 "grep" => {
248 let pattern: String = get_required_arg(args, "pattern")?;
249 let path: String = get_required_arg(args, "path")?;
250 let f: mlua::Function = orcs.get("grep")?;
251 f.call((pattern, path))
252 }
253 "glob" => {
254 let pattern: String = get_required_arg(args, "pattern")?;
255 let dir: Option<String> = args.get("dir").ok();
256 let f: mlua::Function = orcs.get("glob")?;
257 f.call((pattern, dir))
258 }
259 "mkdir" => {
260 let path: String = get_required_arg(args, "path")?;
261 let f: mlua::Function = orcs.get("mkdir")?;
262 f.call(path)
263 }
264 "remove" => {
265 let path: String = get_required_arg(args, "path")?;
266 let f: mlua::Function = orcs.get("remove")?;
267 f.call(path)
268 }
269 "mv" => {
270 let src: String = get_required_arg(args, "src")?;
271 let dst: String = get_required_arg(args, "dst")?;
272 let f: mlua::Function = orcs.get("mv")?;
273 f.call((src, dst))
274 }
275 "exec" => {
276 let cmd: String = get_required_arg(args, "cmd")?;
277 let f: mlua::Function = orcs.get("exec")?;
278 f.call(cmd)
279 }
280 _ => {
281 let result = lua.create_table()?;
284 set_error(
285 &result,
286 &format!("internal dispatch error: no handler for '{name}'"),
287 )?;
288 Ok(result)
289 }
290 }
291}
292
293fn dispatch_component(
302 lua: &Lua,
303 intent_name: &str,
304 component_fqn: &str,
305 operation: &str,
306 args: &Table,
307) -> mlua::Result<Table> {
308 let orcs: Table = lua.globals().get("orcs")?;
309
310 let request_fn = match orcs.get::<mlua::Function>("request") {
311 Ok(f) => f,
312 Err(_) => {
313 let result = lua.create_table()?;
314 set_error(
315 &result,
316 "component dispatch unavailable: no execution context (orcs.request not registered)",
317 )?;
318 return Ok(result);
319 }
320 };
321
322 let payload = lua.create_table()?;
324 for pair in args.pairs::<mlua::Value, mlua::Value>() {
325 let (k, v) = pair?;
326 payload.set(k, v)?;
327 }
328
329 let start = std::time::Instant::now();
331 let rpc_result: Table = request_fn.call((component_fqn, operation, payload))?;
332 let duration_ms = start.elapsed().as_millis() as u64;
333
334 tracing::debug!(
335 "component dispatch: {intent_name} → {component_fqn}::{operation} ({duration_ms}ms)"
336 );
337
338 let result = lua.create_table()?;
340 let success: bool = rpc_result.get("success").unwrap_or(false);
341 result.set("ok", success)?;
342 result.set("duration_ms", duration_ms)?;
343
344 if success {
345 if let Ok(data) = rpc_result.get::<mlua::Value>("data") {
347 result.set("data", data)?;
348 }
349 } else {
350 let error_msg: String = rpc_result
352 .get("error")
353 .unwrap_or_else(|_| format!("component RPC failed: {component_fqn}::{operation}"));
354 result.set("error", error_msg)?;
355 }
356
357 Ok(result)
358}
359
360fn ensure_registry(lua: &Lua) -> mlua::Result<mlua::AppDataRef<'_, IntentRegistry>> {
364 if lua.app_data_ref::<IntentRegistry>().is_none() {
365 lua.set_app_data(IntentRegistry::new());
366 }
367 lua.app_data_ref::<IntentRegistry>().ok_or_else(|| {
368 mlua::Error::RuntimeError("IntentRegistry not available after initialization".into())
369 })
370}
371
372pub fn generate_descriptions(lua: &Lua) -> String {
374 let registry = match ensure_registry(lua) {
375 Ok(r) => r,
376 Err(_) => return "IntentRegistry not available.\n".to_string(),
377 };
378
379 let mut out = String::from("Available tools (use via orcs.dispatch):\n\n");
380
381 for def in registry.all() {
382 let args_fmt = extract_arg_names(&def.parameters);
384 out.push_str(&format!(
385 "{}({}) - {}\n",
386 def.name, args_fmt, def.description
387 ));
388 }
389
390 out.push_str("\norcs.pwd - Project root path (string).\n");
391 out
392}
393
394fn extract_arg_names(schema: &serde_json::Value) -> String {
396 let properties = match schema.get("properties").and_then(|p| p.as_object()) {
397 Some(p) => p,
398 None => return String::new(),
399 };
400
401 let required: Vec<&str> = schema
402 .get("required")
403 .and_then(|r| r.as_array())
404 .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>())
405 .unwrap_or_default();
406
407 properties
408 .keys()
409 .map(|name| {
410 if required.contains(&name.as_str()) {
411 name.clone()
412 } else {
413 format!("{name}?")
414 }
415 })
416 .collect::<Vec<_>>()
417 .join(", ")
418}
419
420fn get_required_arg(args: &Table, name: &str) -> mlua::Result<String> {
424 args.get::<String>(name)
425 .map_err(|_| mlua::Error::RuntimeError(format!("missing required argument: {name}")))
426}
427
428fn set_error(result: &Table, msg: &str) -> mlua::Result<()> {
430 result.set("ok", false)?;
431 result.set("error", msg.to_string())?;
432 Ok(())
433}
434
435pub fn register_dispatch_functions(lua: &Lua) -> Result<(), LuaError> {
445 if lua.app_data_ref::<IntentRegistry>().is_none() {
447 lua.set_app_data(IntentRegistry::new());
448 }
449
450 let orcs_table: Table = lua.globals().get("orcs")?;
451
452 let dispatch_fn =
454 lua.create_function(|lua, (name, args): (String, Table)| dispatch_tool(lua, &name, &args))?;
455 orcs_table.set("dispatch", dispatch_fn)?;
456
457 let schemas_fn = lua.create_function(|lua, ()| {
459 let registry = ensure_registry(lua)?;
460 let result = lua.create_table()?;
461
462 for (i, def) in registry.all().iter().enumerate() {
463 let entry = lua.create_table()?;
464 entry.set("name", def.name.as_str())?;
465 entry.set("description", def.description.as_str())?;
466
467 let args_table = lua.create_table()?;
469 if let Some(properties) = def.parameters.get("properties").and_then(|p| p.as_object()) {
470 let required: Vec<&str> = def
471 .parameters
472 .get("required")
473 .and_then(|r| r.as_array())
474 .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
475 .unwrap_or_default();
476
477 for (j, (prop_name, prop_schema)) in properties.iter().enumerate() {
478 let arg_entry = lua.create_table()?;
479 arg_entry.set("name", prop_name.as_str())?;
480
481 let is_required = required.contains(&prop_name.as_str());
482 let type_str = if is_required { "string" } else { "string?" };
483 arg_entry.set("type", type_str)?;
484 arg_entry.set("required", is_required)?;
485
486 let description = prop_schema
487 .get("description")
488 .and_then(|d| d.as_str())
489 .unwrap_or("");
490 arg_entry.set("description", description)?;
491
492 args_table.set(j + 1, arg_entry)?;
493 }
494 }
495 entry.set("args", args_table)?;
496 result.set(i + 1, entry)?;
497 }
498
499 Ok(result)
500 })?;
501 orcs_table.set("tool_schemas", schemas_fn)?;
502
503 let intent_defs_fn = lua.create_function(|lua, ()| {
505 let registry = ensure_registry(lua)?;
506 let result = lua.create_table()?;
507
508 for (i, def) in registry.all().iter().enumerate() {
509 let entry = lua.create_table()?;
510 entry.set("name", def.name.as_str())?;
511 entry.set("description", def.description.as_str())?;
512
513 let params_value = serde_json_to_lua(&def.parameters, lua)?;
515 entry.set("parameters", params_value)?;
516
517 result.set(i + 1, entry)?;
518 }
519
520 Ok(result)
521 })?;
522 orcs_table.set("intent_defs", intent_defs_fn)?;
523
524 let register_fn = lua.create_function(|lua, def_table: Table| {
526 let name: String = def_table
527 .get("name")
528 .map_err(|_| mlua::Error::RuntimeError("register_intent: 'name' is required".into()))?;
529 let description: String = def_table.get("description").map_err(|_| {
530 mlua::Error::RuntimeError("register_intent: 'description' is required".into())
531 })?;
532
533 let component_fqn: String = def_table.get("component").map_err(|_| {
535 mlua::Error::RuntimeError("register_intent: 'component' is required".into())
536 })?;
537 let operation: String = def_table
538 .get("operation")
539 .unwrap_or_else(|_| "execute".to_string());
540
541 let parameters = match def_table.get::<Table>("params") {
543 Ok(params_table) => {
544 let mut properties = serde_json::Map::new();
546 let mut required = Vec::new();
547
548 for pair in params_table.pairs::<String, Table>() {
549 let (param_name, param_def) = pair?;
550 let type_str: String = param_def
551 .get("type")
552 .unwrap_or_else(|_| "string".to_string());
553 let desc: String = param_def
554 .get("description")
555 .unwrap_or_else(|_| String::new());
556 let is_required: bool = param_def.get("required").unwrap_or(false);
557
558 properties.insert(
559 param_name.clone(),
560 serde_json::json!({
561 "type": type_str,
562 "description": desc,
563 }),
564 );
565 if is_required {
566 required.push(serde_json::Value::String(param_name));
567 }
568 }
569
570 serde_json::json!({
571 "type": "object",
572 "properties": properties,
573 "required": required,
574 })
575 }
576 Err(_) => serde_json::json!({"type": "object", "properties": {}}),
577 };
578
579 let intent_def = IntentDef {
580 name: name.clone(),
581 description,
582 parameters,
583 resolver: IntentResolver::Component {
584 component_fqn,
585 operation,
586 },
587 };
588
589 if let Some(mut registry) = lua.remove_app_data::<IntentRegistry>() {
591 let result = registry.register(intent_def);
592 lua.set_app_data(registry);
593
594 let result_table = lua.create_table()?;
595 match result {
596 Ok(()) => {
597 result_table.set("ok", true)?;
598 }
599 Err(e) => {
600 result_table.set("ok", false)?;
601 result_table.set("error", e)?;
602 }
603 }
604 Ok(result_table)
605 } else {
606 Err(mlua::Error::RuntimeError(
607 "IntentRegistry not initialized".into(),
608 ))
609 }
610 })?;
611 orcs_table.set("register_intent", register_fn)?;
612
613 let desc = generate_descriptions(lua);
615 let tool_desc_fn = lua.create_function(move |_, ()| Ok(desc.clone()))?;
616 orcs_table.set("tool_descriptions", tool_desc_fn)?;
617
618 Ok(())
619}
620
621#[cfg(test)]
622mod tests {
623 use super::*;
624 use crate::orcs_helpers::register_base_orcs_functions;
625 use orcs_runtime::sandbox::{ProjectSandbox, SandboxPolicy};
626 use std::fs;
627 use std::path::PathBuf;
628 use std::sync::Arc;
629
630 fn test_sandbox() -> (PathBuf, Arc<dyn SandboxPolicy>) {
631 let dir = tempdir();
632 let sandbox = ProjectSandbox::new(&dir).expect("test sandbox");
633 (dir, Arc::new(sandbox))
634 }
635
636 fn tempdir() -> PathBuf {
637 let dir = std::env::temp_dir().join(format!(
638 "orcs-registry-test-{}-{}",
639 std::process::id(),
640 std::time::SystemTime::now()
641 .duration_since(std::time::UNIX_EPOCH)
642 .expect("system time should be after epoch")
643 .as_nanos()
644 ));
645 std::fs::create_dir_all(&dir).expect("should create temp dir");
646 dir.canonicalize().expect("should canonicalize temp dir")
647 }
648
649 fn setup_lua(sandbox: Arc<dyn SandboxPolicy>) -> Lua {
650 let lua = Lua::new();
651 register_base_orcs_functions(&lua, sandbox).expect("should register base functions");
652 lua
653 }
654
655 #[test]
658 fn registry_new_has_8_builtins() {
659 let registry = IntentRegistry::new();
660 assert_eq!(registry.len(), 8, "should have 8 builtin intents");
661 }
662
663 #[test]
664 fn registry_get_existing() {
665 let registry = IntentRegistry::new();
666 let def = registry.get("read").expect("'read' should exist");
667 assert_eq!(def.name, "read");
668 assert_eq!(def.resolver, IntentResolver::Internal);
669 }
670
671 #[test]
672 fn registry_get_nonexistent() {
673 let registry = IntentRegistry::new();
674 assert!(registry.get("nonexistent").is_none());
675 }
676
677 #[test]
678 fn registry_register_new_intent() {
679 let mut registry = IntentRegistry::new();
680 let def = IntentDef {
681 name: "custom_tool".into(),
682 description: "A custom tool".into(),
683 parameters: serde_json::json!({"type": "object", "properties": {}}),
684 resolver: IntentResolver::Component {
685 component_fqn: "lua::my_comp".into(),
686 operation: "execute".into(),
687 },
688 };
689 registry
690 .register(def)
691 .expect("should register successfully");
692 assert_eq!(registry.len(), 9);
693 assert!(registry.get("custom_tool").is_some());
694 }
695
696 #[test]
697 fn registry_register_duplicate_fails() {
698 let mut registry = IntentRegistry::new();
699 let def = IntentDef {
700 name: "read".into(),
701 description: "duplicate".into(),
702 parameters: serde_json::json!({}),
703 resolver: IntentResolver::Internal,
704 };
705 let err = registry.register(def).expect_err("should reject duplicate");
706 assert!(
707 err.contains("already registered"),
708 "error should mention duplicate, got: {err}"
709 );
710 }
711
712 #[test]
713 fn registry_all_intent_defs_have_json_schema() {
714 let registry = IntentRegistry::new();
715 for def in registry.all() {
716 assert_eq!(
717 def.parameters.get("type").and_then(|v| v.as_str()),
718 Some("object"),
719 "intent '{}' should have JSON Schema with type=object",
720 def.name
721 );
722 assert!(
723 def.parameters.get("properties").is_some(),
724 "intent '{}' should have properties",
725 def.name
726 );
727 }
728 }
729
730 #[test]
731 fn builtin_intent_names_match_expected() {
732 let registry = IntentRegistry::new();
733 let names: Vec<&str> = registry.all().iter().map(|d| d.name.as_str()).collect();
734 assert_eq!(
735 names,
736 vec!["read", "write", "grep", "glob", "mkdir", "remove", "mv", "exec"]
737 );
738 }
739
740 #[test]
743 fn dispatch_read() {
744 let (root, sandbox) = test_sandbox();
745 fs::write(root.join("test.txt"), "hello dispatch").expect("should write test file");
746
747 let lua = setup_lua(sandbox);
748 let result: Table = lua
749 .load(format!(
750 r#"return orcs.dispatch("read", {{path="{}"}})"#,
751 root.join("test.txt").display()
752 ))
753 .eval()
754 .expect("dispatch read should succeed");
755
756 assert!(result.get::<bool>("ok").expect("should have ok field"));
757 assert_eq!(
758 result
759 .get::<String>("content")
760 .expect("should have content"),
761 "hello dispatch"
762 );
763 }
764
765 #[test]
766 fn dispatch_write_and_read() {
767 let (root, sandbox) = test_sandbox();
768 let path = root.join("written.txt");
769
770 let lua = setup_lua(sandbox);
771 let code = format!(
772 r#"
773 local w = orcs.dispatch("write", {{path="{p}", content="via dispatch"}})
774 local r = orcs.dispatch("read", {{path="{p}"}})
775 return r
776 "#,
777 p = path.display()
778 );
779 let result: Table = lua
780 .load(&code)
781 .eval()
782 .expect("dispatch write+read should succeed");
783 assert!(result.get::<bool>("ok").expect("should have ok field"));
784 assert_eq!(
785 result
786 .get::<String>("content")
787 .expect("should have content"),
788 "via dispatch"
789 );
790 }
791
792 #[test]
793 fn dispatch_grep() {
794 let (root, sandbox) = test_sandbox();
795 fs::write(root.join("search.txt"), "line one\nline two\nthird")
796 .expect("should write search file");
797
798 let lua = setup_lua(sandbox);
799 let result: Table = lua
800 .load(format!(
801 r#"return orcs.dispatch("grep", {{pattern="line", path="{}"}})"#,
802 root.join("search.txt").display()
803 ))
804 .eval()
805 .expect("dispatch grep should succeed");
806
807 assert!(result.get::<bool>("ok").expect("should have ok field"));
808 assert_eq!(result.get::<usize>("count").expect("should have count"), 2);
809 }
810
811 #[test]
812 fn dispatch_glob() {
813 let (root, sandbox) = test_sandbox();
814 fs::write(root.join("a.rs"), "").expect("write a.rs");
815 fs::write(root.join("b.rs"), "").expect("write b.rs");
816 fs::write(root.join("c.txt"), "").expect("write c.txt");
817
818 let lua = setup_lua(sandbox);
819 let result: Table = lua
820 .load(format!(
821 r#"return orcs.dispatch("glob", {{pattern="*.rs", dir="{}"}})"#,
822 root.display()
823 ))
824 .eval()
825 .expect("dispatch glob should succeed");
826
827 assert!(result.get::<bool>("ok").expect("should have ok field"));
828 assert_eq!(result.get::<usize>("count").expect("should have count"), 2);
829 }
830
831 #[test]
832 fn dispatch_mkdir_remove() {
833 let (root, sandbox) = test_sandbox();
834 let dir_path = root.join("sub/deep");
835
836 let lua = setup_lua(sandbox);
837 let code = format!(
838 r#"
839 local m = orcs.dispatch("mkdir", {{path="{p}"}})
840 local r = orcs.dispatch("remove", {{path="{p}"}})
841 return {{mkdir=m, remove=r}}
842 "#,
843 p = dir_path.display()
844 );
845 let result: Table = lua
846 .load(&code)
847 .eval()
848 .expect("dispatch mkdir+remove should succeed");
849 let mkdir: Table = result.get("mkdir").expect("should have mkdir");
850 let remove: Table = result.get("remove").expect("should have remove");
851 assert!(mkdir.get::<bool>("ok").expect("mkdir ok"));
852 assert!(remove.get::<bool>("ok").expect("remove ok"));
853 }
854
855 #[test]
856 fn dispatch_mv() {
857 let (root, sandbox) = test_sandbox();
858 let src = root.join("src.txt");
859 let dst = root.join("dst.txt");
860 fs::write(&src, "move me").expect("write src");
861
862 let lua = setup_lua(sandbox);
863 let result: Table = lua
864 .load(format!(
865 r#"return orcs.dispatch("mv", {{src="{}", dst="{}"}})"#,
866 src.display(),
867 dst.display()
868 ))
869 .eval()
870 .expect("dispatch mv should succeed");
871
872 assert!(result.get::<bool>("ok").expect("should have ok field"));
873 assert!(dst.exists());
874 assert!(!src.exists());
875 }
876
877 #[test]
878 fn dispatch_unknown_tool() {
879 let (_, sandbox) = test_sandbox();
880 let lua = setup_lua(sandbox);
881
882 let result: Table = lua
883 .load(r#"return orcs.dispatch("nonexistent", {arg="val"})"#)
884 .eval()
885 .expect("dispatch unknown should return error table");
886
887 assert!(!result.get::<bool>("ok").expect("should have ok field"));
888 assert!(result
889 .get::<String>("error")
890 .expect("should have error")
891 .contains("unknown intent"));
892 }
893
894 #[test]
895 fn dispatch_missing_required_arg() {
896 let (_, sandbox) = test_sandbox();
897 let lua = setup_lua(sandbox);
898
899 let result = lua
900 .load(r#"return orcs.dispatch("read", {})"#)
901 .eval::<Table>();
902
903 assert!(result.is_err());
904 let err = result.expect_err("should error on missing arg").to_string();
905 assert!(err.contains("missing required argument"), "got: {err}");
906 }
907
908 #[test]
911 fn tool_schemas_returns_all() {
912 let (_, sandbox) = test_sandbox();
913 let lua = setup_lua(sandbox);
914
915 let schemas: Table = lua
916 .load("return orcs.tool_schemas()")
917 .eval()
918 .expect("tool_schemas should return table");
919
920 let count = schemas.len().expect("should have length") as usize;
921 assert_eq!(count, 8, "should return 8 builtin tools");
922
923 let first: Table = schemas.get(1).expect("should have first entry");
925 assert_eq!(
926 first.get::<String>("name").expect("should have name"),
927 "read"
928 );
929 assert!(!first
930 .get::<String>("description")
931 .expect("should have description")
932 .is_empty());
933
934 let args: Table = first.get("args").expect("should have args");
935 let first_arg: Table = args.get(1).expect("should have first arg");
936 assert_eq!(first_arg.get::<String>("name").expect("arg name"), "path");
937 assert_eq!(first_arg.get::<String>("type").expect("arg type"), "string");
938 assert!(first_arg.get::<bool>("required").expect("arg required"));
939 }
940
941 #[test]
944 fn descriptions_include_all_tools() {
945 let lua = Lua::new();
946 lua.set_app_data(IntentRegistry::new());
947 let desc = generate_descriptions(&lua);
948 let expected_tools = [
949 "read", "write", "grep", "glob", "mkdir", "remove", "mv", "exec",
950 ];
951 for tool in expected_tools {
952 assert!(desc.contains(tool), "missing tool in descriptions: {tool}");
953 }
954 }
955
956 #[test]
959 fn dispatch_exec_uses_registered_exec() {
960 let (_, sandbox) = test_sandbox();
961 let lua = setup_lua(sandbox);
962
963 let result: Table = lua
965 .load(r#"return orcs.dispatch("exec", {cmd="echo hi"})"#)
966 .eval()
967 .expect("dispatch exec should return table");
968
969 assert!(!result.get::<bool>("ok").expect("should have ok field"));
971 }
972
973 #[test]
976 fn intent_defs_returns_all() {
977 let (_, sandbox) = test_sandbox();
978 let lua = setup_lua(sandbox);
979
980 let defs: Table = lua
981 .load("return orcs.intent_defs()")
982 .eval()
983 .expect("intent_defs should return table");
984
985 let count = defs.len().expect("should have length") as usize;
986 assert_eq!(count, 8, "should return 8 builtin intents");
987
988 let first: Table = defs.get(1).expect("should have first entry");
989 assert_eq!(
990 first.get::<String>("name").expect("should have name"),
991 "read"
992 );
993 assert!(!first
994 .get::<String>("description")
995 .expect("should have description")
996 .is_empty());
997
998 assert!(
1000 first.get::<mlua::Value>("parameters").is_ok(),
1001 "should have parameters"
1002 );
1003 }
1004
1005 #[test]
1008 fn register_intent_adds_to_registry() {
1009 let (_, sandbox) = test_sandbox();
1010 let lua = setup_lua(sandbox);
1011
1012 let result: Table = lua
1013 .load(
1014 r#"
1015 return orcs.register_intent({
1016 name = "custom_action",
1017 description = "A custom action",
1018 component = "lua::my_component",
1019 operation = "do_stuff",
1020 params = {
1021 input = { type = "string", description = "Input data", required = true },
1022 },
1023 })
1024 "#,
1025 )
1026 .eval()
1027 .expect("register_intent should return table");
1028
1029 assert!(
1030 result.get::<bool>("ok").expect("should have ok field"),
1031 "registration should succeed"
1032 );
1033
1034 let schemas: Table = lua
1036 .load("return orcs.tool_schemas()")
1037 .eval()
1038 .expect("tool_schemas after register");
1039 let count = schemas.len().expect("should have length") as usize;
1040 assert_eq!(count, 9, "should now have 9 intents (8 builtin + 1 custom)");
1041 }
1042
1043 #[test]
1044 fn register_intent_duplicate_fails() {
1045 let (_, sandbox) = test_sandbox();
1046 let lua = setup_lua(sandbox);
1047
1048 let result: Table = lua
1049 .load(
1050 r#"
1051 return orcs.register_intent({
1052 name = "read",
1053 description = "duplicate",
1054 component = "lua::x",
1055 })
1056 "#,
1057 )
1058 .eval()
1059 .expect("register_intent should return table");
1060
1061 assert!(
1062 !result.get::<bool>("ok").expect("should have ok field"),
1063 "duplicate registration should fail"
1064 );
1065 assert!(result
1066 .get::<String>("error")
1067 .expect("should have error")
1068 .contains("already registered"));
1069 }
1070
1071 #[test]
1074 fn dispatch_component_no_request_fn_returns_error() {
1075 let (_, sandbox) = test_sandbox();
1076 let lua = setup_lua(sandbox);
1077
1078 lua.load(
1080 r#"
1081 orcs.register_intent({
1082 name = "comp_action",
1083 description = "component action",
1084 component = "lua::test_comp",
1085 operation = "do_stuff",
1086 })
1087 "#,
1088 )
1089 .exec()
1090 .expect("register should succeed");
1091
1092 let result: Table = lua
1093 .load(r#"return orcs.dispatch("comp_action", {input="hello"})"#)
1094 .eval()
1095 .expect("should return error table");
1096
1097 assert!(
1098 !result.get::<bool>("ok").expect("should have ok"),
1099 "should fail without orcs.request"
1100 );
1101 let error: String = result.get("error").expect("should have error");
1102 assert!(
1103 error.contains("no execution context"),
1104 "error should mention missing context, got: {error}"
1105 );
1106 }
1107
1108 #[test]
1109 fn dispatch_component_success_normalized() {
1110 let (_, sandbox) = test_sandbox();
1111 let lua = setup_lua(sandbox);
1112
1113 lua.load(
1115 r#"
1116 orcs.register_intent({
1117 name = "mock_comp",
1118 description = "mock component",
1119 component = "lua::mock",
1120 operation = "echo",
1121 })
1122 "#,
1123 )
1124 .exec()
1125 .expect("register should succeed");
1126
1127 lua.load(
1129 r#"
1130 orcs.request = function(target, operation, payload)
1131 return { success = true, data = { echo = payload.input, target = target, op = operation } }
1132 end
1133 "#,
1134 )
1135 .exec()
1136 .expect("mock should succeed");
1137
1138 let result: Table = lua
1139 .load(r#"return orcs.dispatch("mock_comp", {input="hello"})"#)
1140 .eval()
1141 .expect("dispatch should return table");
1142
1143 assert!(
1145 result.get::<bool>("ok").expect("should have ok"),
1146 "should succeed"
1147 );
1148
1149 let duration: u64 = result.get("duration_ms").expect("should have duration_ms");
1151 assert!(
1152 duration < 1000,
1153 "local mock should be fast, got: {duration}ms"
1154 );
1155
1156 let data: Table = result.get("data").expect("should have data");
1158 assert_eq!(
1159 data.get::<String>("echo").expect("should have echo"),
1160 "hello"
1161 );
1162 assert_eq!(
1163 data.get::<String>("target").expect("should have target"),
1164 "lua::mock"
1165 );
1166 assert_eq!(data.get::<String>("op").expect("should have op"), "echo");
1167 }
1168
1169 #[test]
1170 fn dispatch_component_failure_normalized() {
1171 let (_, sandbox) = test_sandbox();
1172 let lua = setup_lua(sandbox);
1173
1174 lua.load(
1176 r#"
1177 orcs.register_intent({
1178 name = "fail_comp",
1179 description = "failing component",
1180 component = "lua::fail",
1181 operation = "explode",
1182 })
1183 orcs.request = function(target, operation, payload)
1184 return { success = false, error = "component exploded" }
1185 end
1186 "#,
1187 )
1188 .exec()
1189 .expect("setup should succeed");
1190
1191 let result: Table = lua
1192 .load(r#"return orcs.dispatch("fail_comp", {})"#)
1193 .eval()
1194 .expect("dispatch should return table");
1195
1196 assert!(
1197 !result.get::<bool>("ok").expect("should have ok"),
1198 "should report failure"
1199 );
1200 let error: String = result.get("error").expect("should have error");
1201 assert_eq!(error, "component exploded");
1202
1203 assert!(result.get::<u64>("duration_ms").is_ok());
1205 }
1206
1207 #[test]
1208 fn dispatch_component_forwards_all_args() {
1209 let (_, sandbox) = test_sandbox();
1210 let lua = setup_lua(sandbox);
1211
1212 lua.load(
1213 r#"
1214 orcs.register_intent({
1215 name = "args_comp",
1216 description = "args test",
1217 component = "lua::args_test",
1218 operation = "check_args",
1219 })
1220 -- Mock that captures and returns the payload
1221 orcs.request = function(target, operation, payload)
1222 return { success = true, data = payload }
1223 end
1224 "#,
1225 )
1226 .exec()
1227 .expect("setup should succeed");
1228
1229 let result: Table = lua
1230 .load(r#"return orcs.dispatch("args_comp", {a="1", b="2", c="3"})"#)
1231 .eval()
1232 .expect("dispatch should return table");
1233
1234 assert!(result.get::<bool>("ok").expect("should have ok"));
1235 let data: Table = result.get("data").expect("should have data");
1236 assert_eq!(data.get::<String>("a").expect("arg a"), "1");
1237 assert_eq!(data.get::<String>("b").expect("arg b"), "2");
1238 assert_eq!(data.get::<String>("c").expect("arg c"), "3");
1239 }
1240
1241 #[test]
1244 fn register_intent_missing_name_errors() {
1245 let (_, sandbox) = test_sandbox();
1246 let lua = setup_lua(sandbox);
1247
1248 let result = lua
1249 .load(
1250 r#"
1251 return orcs.register_intent({
1252 description = "no name",
1253 component = "lua::x",
1254 })
1255 "#,
1256 )
1257 .eval::<Table>();
1258
1259 assert!(result.is_err(), "missing 'name' should cause a Lua error");
1260 let err = result.expect_err("should error").to_string();
1261 assert!(
1262 err.contains("name"),
1263 "error should mention 'name', got: {err}"
1264 );
1265 }
1266
1267 #[test]
1268 fn register_intent_missing_description_errors() {
1269 let (_, sandbox) = test_sandbox();
1270 let lua = setup_lua(sandbox);
1271
1272 let result = lua
1273 .load(
1274 r#"
1275 return orcs.register_intent({
1276 name = "no_desc",
1277 component = "lua::x",
1278 })
1279 "#,
1280 )
1281 .eval::<Table>();
1282
1283 assert!(
1284 result.is_err(),
1285 "missing 'description' should cause a Lua error"
1286 );
1287 let err = result.expect_err("should error").to_string();
1288 assert!(
1289 err.contains("description"),
1290 "error should mention 'description', got: {err}"
1291 );
1292 }
1293
1294 #[test]
1295 fn register_intent_missing_component_errors() {
1296 let (_, sandbox) = test_sandbox();
1297 let lua = setup_lua(sandbox);
1298
1299 let result = lua
1300 .load(
1301 r#"
1302 return orcs.register_intent({
1303 name = "no_comp",
1304 description = "missing component",
1305 })
1306 "#,
1307 )
1308 .eval::<Table>();
1309
1310 assert!(
1311 result.is_err(),
1312 "missing 'component' should cause a Lua error"
1313 );
1314 let err = result.expect_err("should error").to_string();
1315 assert!(
1316 err.contains("component"),
1317 "error should mention 'component', got: {err}"
1318 );
1319 }
1320
1321 #[test]
1322 fn register_intent_defaults_operation_to_execute() {
1323 let (_, sandbox) = test_sandbox();
1324 let lua = setup_lua(sandbox);
1325
1326 let result: Table = lua
1328 .load(
1329 r#"
1330 return orcs.register_intent({
1331 name = "default_op",
1332 description = "test default operation",
1333 component = "lua::test_comp",
1334 })
1335 "#,
1336 )
1337 .eval()
1338 .expect("register_intent should return table");
1339
1340 assert!(
1341 result.get::<bool>("ok").expect("should have ok"),
1342 "registration should succeed"
1343 );
1344
1345 lua.load(
1348 r#"
1349 orcs.request = function(target, operation, payload)
1350 return { success = true, data = { captured_op = operation } }
1351 end
1352 "#,
1353 )
1354 .exec()
1355 .expect("mock should succeed");
1356
1357 let dispatch_result: Table = lua
1358 .load(r#"return orcs.dispatch("default_op", {})"#)
1359 .eval()
1360 .expect("dispatch should return table");
1361
1362 assert!(dispatch_result.get::<bool>("ok").expect("should have ok"));
1363 let data: Table = dispatch_result.get("data").expect("should have data");
1364 assert_eq!(
1365 data.get::<String>("captured_op").expect("captured_op"),
1366 "execute",
1367 "operation should default to 'execute'"
1368 );
1369 }
1370}