1use crate::capability::Capability;
61use crate::context::ChildContext;
62use orcs_auth::SandboxPolicy;
63use orcs_types::intent::{IntentDef, IntentResolver};
64use thiserror::Error;
65
66#[derive(Debug, Clone, Error)]
73#[error("{message}")]
74pub struct ToolError {
75 message: String,
76}
77
78impl ToolError {
79 pub fn new(msg: impl Into<String>) -> Self {
81 Self {
82 message: msg.into(),
83 }
84 }
85
86 pub fn message(&self) -> &str {
88 &self.message
89 }
90}
91
92impl From<String> for ToolError {
93 fn from(s: String) -> Self {
94 Self::new(s)
95 }
96}
97
98impl From<std::io::Error> for ToolError {
99 fn from(e: std::io::Error) -> Self {
100 Self::new(e.to_string())
101 }
102}
103
104impl From<orcs_auth::SandboxError> for ToolError {
105 fn from(e: orcs_auth::SandboxError) -> Self {
106 Self::new(e.to_string())
107 }
108}
109
110pub struct ToolContext<'a> {
124 sandbox: &'a dyn SandboxPolicy,
125 child_ctx: Option<&'a dyn ChildContext>,
126}
127
128impl<'a> ToolContext<'a> {
129 pub fn new(sandbox: &'a dyn SandboxPolicy) -> Self {
131 Self {
132 sandbox,
133 child_ctx: None,
134 }
135 }
136
137 #[must_use]
139 pub fn with_child_ctx(mut self, ctx: &'a dyn ChildContext) -> Self {
140 self.child_ctx = Some(ctx);
141 self
142 }
143
144 pub fn sandbox(&self) -> &dyn SandboxPolicy {
146 self.sandbox
147 }
148
149 pub fn child_ctx(&self) -> Option<&dyn ChildContext> {
154 self.child_ctx
155 }
156}
157
158impl std::fmt::Debug for ToolContext<'_> {
159 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
160 f.debug_struct("ToolContext")
161 .field("sandbox_root", &self.sandbox.root())
162 .field("has_child_ctx", &self.child_ctx.is_some())
163 .finish()
164 }
165}
166
167pub trait RustTool: Send + Sync {
186 fn name(&self) -> &str;
190
191 fn description(&self) -> &str;
193
194 fn parameters_schema(&self) -> serde_json::Value;
198
199 fn required_capability(&self) -> Capability;
203
204 fn is_read_only(&self) -> bool;
209
210 fn execute(
221 &self,
222 args: serde_json::Value,
223 ctx: &ToolContext<'_>,
224 ) -> Result<serde_json::Value, ToolError>;
225
226 fn intent_def(&self) -> IntentDef {
236 IntentDef {
237 name: self.name().to_string(),
238 description: self.description().to_string(),
239 parameters: self.parameters_schema(),
240 resolver: IntentResolver::Internal,
241 }
242 }
243}
244
245const _: () = {
249 fn _assert_object_safe(_: &dyn RustTool) {}
250};
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255 use serde_json::json;
256
257 #[derive(Debug)]
260 struct StubSandbox {
261 root: std::path::PathBuf,
262 }
263
264 impl StubSandbox {
265 fn new(root: &str) -> Self {
266 Self {
267 root: std::path::PathBuf::from(root),
268 }
269 }
270 }
271
272 impl SandboxPolicy for StubSandbox {
273 fn project_root(&self) -> &std::path::Path {
274 &self.root
275 }
276 fn root(&self) -> &std::path::Path {
277 &self.root
278 }
279 fn validate_read(&self, path: &str) -> Result<std::path::PathBuf, orcs_auth::SandboxError> {
280 Ok(self.root.join(path))
281 }
282 fn validate_write(
283 &self,
284 path: &str,
285 ) -> Result<std::path::PathBuf, orcs_auth::SandboxError> {
286 Ok(self.root.join(path))
287 }
288 }
289
290 struct EchoTool;
293
294 impl RustTool for EchoTool {
295 fn name(&self) -> &str {
296 "echo"
297 }
298 fn description(&self) -> &str {
299 "Echo back the input"
300 }
301 fn parameters_schema(&self) -> serde_json::Value {
302 json!({
303 "type": "object",
304 "properties": {
305 "message": { "type": "string", "description": "Message to echo" }
306 },
307 "required": ["message"]
308 })
309 }
310 fn required_capability(&self) -> Capability {
311 Capability::READ
312 }
313 fn is_read_only(&self) -> bool {
314 true
315 }
316 fn execute(
317 &self,
318 args: serde_json::Value,
319 _ctx: &ToolContext<'_>,
320 ) -> Result<serde_json::Value, ToolError> {
321 let msg = args["message"]
322 .as_str()
323 .ok_or_else(|| ToolError::new("missing 'message'"))?;
324 Ok(json!({ "echoed": msg }))
325 }
326 }
327
328 #[test]
331 fn tool_identity() {
332 let tool = EchoTool;
333 assert_eq!(tool.name(), "echo");
334 assert_eq!(tool.description(), "Echo back the input");
335 assert!(tool.is_read_only());
336 assert_eq!(tool.required_capability(), Capability::READ);
337 }
338
339 #[test]
340 fn tool_parameters_schema_is_object() {
341 let tool = EchoTool;
342 let schema = tool.parameters_schema();
343 assert_eq!(
344 schema["type"], "object",
345 "parameters_schema must have type=object"
346 );
347 assert!(
348 schema["properties"]["message"].is_object(),
349 "should define 'message' property"
350 );
351 }
352
353 #[test]
354 fn tool_execute_success() {
355 let tool = EchoTool;
356 let sandbox = StubSandbox::new("/project");
357 let ctx = ToolContext::new(&sandbox);
358
359 let result = tool.execute(json!({"message": "hello"}), &ctx);
360 let value = result.expect("should succeed");
361 assert_eq!(value["echoed"], "hello");
362 }
363
364 #[test]
365 fn tool_execute_missing_arg() {
366 let tool = EchoTool;
367 let sandbox = StubSandbox::new("/project");
368 let ctx = ToolContext::new(&sandbox);
369
370 let result = tool.execute(json!({}), &ctx);
371 let err = result.expect_err("should fail with missing arg");
372 assert!(
373 err.message().contains("missing"),
374 "error should mention missing arg, got: {}",
375 err.message()
376 );
377 }
378
379 #[test]
380 fn tool_intent_def_default() {
381 let tool = EchoTool;
382 let def = tool.intent_def();
383
384 assert_eq!(def.name, "echo");
385 assert_eq!(def.description, "Echo back the input");
386 assert_eq!(def.resolver, IntentResolver::Internal);
387 assert_eq!(def.parameters["type"], "object");
388 }
389
390 #[test]
391 fn tool_error_from_string() {
392 let err = ToolError::from("something went wrong".to_string());
393 assert_eq!(err.message(), "something went wrong");
394 assert_eq!(err.to_string(), "something went wrong");
395 }
396
397 #[test]
398 fn tool_error_from_io() {
399 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
400 let err = ToolError::from(io_err);
401 assert!(
402 err.message().contains("file not found"),
403 "got: {}",
404 err.message()
405 );
406 }
407
408 #[test]
409 fn tool_error_from_sandbox() {
410 let sandbox_err = orcs_auth::SandboxError::OutsideBoundary {
411 path: "/etc/passwd".into(),
412 root: "/project".into(),
413 };
414 let err = ToolError::from(sandbox_err);
415 assert!(
416 err.message().contains("/etc/passwd"),
417 "got: {}",
418 err.message()
419 );
420 }
421
422 #[test]
423 fn tool_error_clone() {
424 let err = ToolError::new("test");
425 let cloned = err.clone();
426 assert_eq!(err.message(), cloned.message());
427 }
428
429 #[test]
430 fn tool_context_debug() {
431 let sandbox = StubSandbox::new("/project");
432 let ctx = ToolContext::new(&sandbox);
433 let debug = format!("{:?}", ctx);
434 assert!(debug.contains("ToolContext"), "got: {debug}");
435 assert!(debug.contains("/project"), "got: {debug}");
436 assert!(debug.contains("has_child_ctx"), "got: {debug}");
437 }
438
439 #[test]
440 fn tool_context_without_child_ctx() {
441 let sandbox = StubSandbox::new("/project");
442 let ctx = ToolContext::new(&sandbox);
443 assert!(ctx.child_ctx().is_none());
444 assert_eq!(ctx.sandbox().root(), std::path::Path::new("/project"));
445 }
446
447 #[test]
448 fn tool_object_safety() {
449 let tool: Box<dyn RustTool> = Box::new(EchoTool);
450 assert_eq!(tool.name(), "echo");
451
452 let sandbox = StubSandbox::new("/project");
453 let ctx = ToolContext::new(&sandbox);
454 let result = tool.execute(json!({"message": "dyn dispatch"}), &ctx);
455 assert!(result.is_ok());
456 }
457
458 #[test]
459 fn tool_arc_dyn() {
460 let tool: std::sync::Arc<dyn RustTool> = std::sync::Arc::new(EchoTool);
461 let clone = std::sync::Arc::clone(&tool);
462 assert_eq!(tool.name(), clone.name());
463 }
464
465 struct WriteTool;
468
469 impl RustTool for WriteTool {
470 fn name(&self) -> &str {
471 "write_stub"
472 }
473 fn description(&self) -> &str {
474 "Stub write tool"
475 }
476 fn parameters_schema(&self) -> serde_json::Value {
477 json!({
478 "type": "object",
479 "properties": {
480 "path": { "type": "string" },
481 "content": { "type": "string" }
482 },
483 "required": ["path", "content"]
484 })
485 }
486 fn required_capability(&self) -> Capability {
487 Capability::WRITE
488 }
489 fn is_read_only(&self) -> bool {
490 false
491 }
492 fn execute(
493 &self,
494 args: serde_json::Value,
495 ctx: &ToolContext<'_>,
496 ) -> Result<serde_json::Value, ToolError> {
497 let path = args["path"]
498 .as_str()
499 .ok_or_else(|| ToolError::new("missing 'path'"))?;
500 let _target = ctx.sandbox().validate_write(path)?;
501 Ok(json!({ "bytes_written": 42 }))
502 }
503 }
504
505 #[test]
506 fn mutation_tool_not_read_only() {
507 let tool = WriteTool;
508 assert!(!tool.is_read_only());
509 assert_eq!(tool.required_capability(), Capability::WRITE);
510 }
511
512 #[test]
513 fn mutation_tool_intent_def() {
514 let tool = WriteTool;
515 let def = tool.intent_def();
516 assert_eq!(def.name, "write_stub");
517 assert_eq!(def.resolver, IntentResolver::Internal);
518 }
519}