use crate::mcp_client::McpClient;
use kernex_core::context::{McpServer, Toolbox};
use kernex_core::message::{CompletionMeta, Response};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tracing::{debug, warn};
const MAX_BASH_OUTPUT: usize = 30_000;
const MAX_READ_OUTPUT: usize = 50_000;
const BASH_TIMEOUT_SECS: u64 = 120;
const TOOLBOX_TIMEOUT_SECS: u64 = 120;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolDef {
pub name: String,
pub description: String,
pub parameters: Value,
}
#[derive(Debug, Clone)]
pub struct ToolResult {
pub content: String,
pub is_error: bool,
}
pub struct ToolExecutor {
workspace_path: PathBuf,
data_dir: PathBuf,
config_path: Option<PathBuf>,
mcp_clients: HashMap<String, McpClient>,
mcp_tool_map: HashMap<String, String>,
toolboxes: HashMap<String, Toolbox>,
}
impl ToolExecutor {
pub fn new(workspace_path: PathBuf) -> Self {
let data_dir = workspace_path
.parent()
.unwrap_or(&workspace_path)
.to_path_buf();
Self {
workspace_path,
data_dir,
config_path: None,
mcp_clients: HashMap::new(),
mcp_tool_map: HashMap::new(),
toolboxes: HashMap::new(),
}
}
#[allow(dead_code)]
pub fn with_config_path(mut self, config_path: PathBuf) -> Self {
self.config_path = Some(config_path);
self
}
pub fn register_toolboxes(&mut self, toolboxes: &[Toolbox]) {
for tb in toolboxes {
debug!("toolbox: registered '{}'", tb.name);
self.toolboxes.insert(tb.name.clone(), tb.clone());
}
}
pub async fn connect_mcp_servers(&mut self, servers: &[McpServer]) {
for server in servers {
match McpClient::connect(&server.name, &server.command, &server.args, &server.env).await
{
Ok(client) => {
for tool in &client.tools {
self.mcp_tool_map
.insert(tool.name.clone(), server.name.clone());
}
self.mcp_clients.insert(server.name.clone(), client);
}
Err(e) => {
warn!("mcp: failed to connect to '{}': {e}", server.name);
}
}
}
}
pub fn all_tool_defs(&self) -> Vec<ToolDef> {
let mut defs = builtin_tool_defs();
for tb in self.toolboxes.values() {
defs.push(ToolDef {
name: tb.name.clone(),
description: tb.description.clone(),
parameters: tb.parameters.clone(),
});
}
for client in self.mcp_clients.values() {
for mcp_tool in &client.tools {
defs.push(ToolDef {
name: mcp_tool.name.clone(),
description: mcp_tool.description.clone(),
parameters: mcp_tool.input_schema.clone(),
});
}
}
defs
}
pub async fn execute(&mut self, tool_name: &str, args: &Value) -> ToolResult {
match tool_name.to_lowercase().as_str() {
"bash" => self.exec_bash(args).await,
"read" => self.exec_read(args).await,
"write" => self.exec_write(args).await,
"edit" => self.exec_edit(args).await,
_ => {
if let Some(tb) = self.toolboxes.get(tool_name).cloned() {
return self.exec_toolbox(&tb, args).await;
}
if let Some(server_name) = self.mcp_tool_map.get(tool_name).cloned() {
if let Some(client) = self.mcp_clients.get_mut(&server_name) {
match client.call_tool(tool_name, args).await {
Ok(r) => ToolResult {
content: r.content,
is_error: r.is_error,
},
Err(e) => ToolResult {
content: format!("MCP error: {e}"),
is_error: true,
},
}
} else {
ToolResult {
content: format!("MCP server '{server_name}' not connected"),
is_error: true,
}
}
} else {
ToolResult {
content: format!("Unknown tool: {tool_name}"),
is_error: true,
}
}
}
}
}
pub async fn shutdown_mcp(&mut self) {
for (name, client) in self.mcp_clients.drain() {
debug!("mcp: shutting down '{name}'");
client.shutdown().await;
}
self.mcp_tool_map.clear();
}
fn resolve_path(&self, path_str: &str) -> PathBuf {
let p = Path::new(path_str);
let joined = if p.is_absolute() {
p.to_path_buf()
} else {
self.workspace_path.join(p)
};
normalize_path(&joined)
}
async fn exec_bash(&self, args: &Value) -> ToolResult {
let command = args.get("command").and_then(|v| v.as_str()).unwrap_or("");
if command.is_empty() {
return ToolResult {
content: "Error: 'command' parameter is required".to_string(),
is_error: true,
};
}
debug!("tool/bash: {command}");
let mut cmd = kernex_sandbox::protected_command("bash", &self.data_dir);
cmd.arg("-c").arg(command);
cmd.current_dir(&self.workspace_path);
cmd.kill_on_drop(true);
match tokio::time::timeout(
std::time::Duration::from_secs(BASH_TIMEOUT_SECS),
cmd.output(),
)
.await
{
Ok(Ok(output)) => {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let mut result = String::new();
if !stdout.is_empty() {
result.push_str(&stdout);
}
if !stderr.is_empty() {
if !result.is_empty() {
result.push('\n');
}
result.push_str(&stderr);
}
if result.is_empty() {
result = format!("(exit code: {})", output.status.code().unwrap_or(-1));
}
let is_error = !output.status.success();
ToolResult {
content: truncate_output(&result, MAX_BASH_OUTPUT),
is_error,
}
}
Ok(Err(e)) => ToolResult {
content: format!("Failed to execute command: {e}"),
is_error: true,
},
Err(_) => ToolResult {
content: format!("Command timed out after {BASH_TIMEOUT_SECS}s"),
is_error: true,
},
}
}
async fn exec_read(&self, args: &Value) -> ToolResult {
let path_str = args.get("file_path").and_then(|v| v.as_str()).unwrap_or("");
if path_str.is_empty() {
return ToolResult {
content: "Error: 'file_path' parameter is required".to_string(),
is_error: true,
};
}
let path = self.resolve_path(path_str);
let path = path.as_path();
if kernex_sandbox::is_read_blocked(path, &self.data_dir, self.config_path.as_deref()) {
return ToolResult {
content: format!("Read denied: {} is a protected path", path.display()),
is_error: true,
};
}
debug!("tool/read: {}", path.display());
match tokio::fs::read_to_string(path).await {
Ok(content) => ToolResult {
content: truncate_output(&content, MAX_READ_OUTPUT),
is_error: false,
},
Err(e) => ToolResult {
content: format!("Error reading {}: {e}", path.display()),
is_error: true,
},
}
}
async fn exec_write(&self, args: &Value) -> ToolResult {
let path_str = args.get("file_path").and_then(|v| v.as_str()).unwrap_or("");
let content = args.get("content").and_then(|v| v.as_str()).unwrap_or("");
if path_str.is_empty() {
return ToolResult {
content: "Error: 'file_path' parameter is required".to_string(),
is_error: true,
};
}
let path = self.resolve_path(path_str);
let path = path.as_path();
if kernex_sandbox::is_write_blocked(path, &self.data_dir) {
return ToolResult {
content: format!("Write denied: {} is a protected path", path.display(),),
is_error: true,
};
}
debug!("tool/write: {}", path.display());
if let Some(parent) = path.parent() {
if let Err(e) = tokio::fs::create_dir_all(parent).await {
return ToolResult {
content: format!("Failed to create parent directory: {e}"),
is_error: true,
};
}
}
match tokio::fs::write(path, content).await {
Ok(()) => ToolResult {
content: format!("Wrote {} bytes to {}", content.len(), path.display()),
is_error: false,
},
Err(e) => ToolResult {
content: format!("Error writing {}: {e}", path.display()),
is_error: true,
},
}
}
async fn exec_toolbox(&self, tb: &Toolbox, args: &Value) -> ToolResult {
debug!("toolbox/{}: running", tb.name);
let mut cmd = kernex_sandbox::protected_command(&tb.command, &self.data_dir);
cmd.args(&tb.args);
cmd.current_dir(&self.workspace_path);
cmd.kill_on_drop(true);
cmd.stdin(std::process::Stdio::piped());
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
for (k, v) in &tb.env {
cmd.env(k, v);
}
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) => {
return ToolResult {
content: format!("Failed to spawn toolbox '{}': {e}", tb.name),
is_error: true,
};
}
};
if let Some(mut stdin) = child.stdin.take() {
let json = serde_json::to_string(args).unwrap_or_default();
let _ = tokio::io::AsyncWriteExt::write_all(&mut stdin, json.as_bytes()).await;
}
match tokio::time::timeout(
std::time::Duration::from_secs(TOOLBOX_TIMEOUT_SECS),
child.wait_with_output(),
)
.await
{
Ok(Ok(output)) => {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let mut result = String::new();
if !stdout.is_empty() {
result.push_str(&stdout);
}
if !stderr.is_empty() {
if !result.is_empty() {
result.push('\n');
}
result.push_str(&stderr);
}
if result.is_empty() {
result = format!("(exit code: {})", output.status.code().unwrap_or(-1));
}
ToolResult {
content: truncate_output(&result, MAX_BASH_OUTPUT),
is_error: !output.status.success(),
}
}
Ok(Err(e)) => ToolResult {
content: format!("Toolbox '{}' execution failed: {e}", tb.name),
is_error: true,
},
Err(_) => ToolResult {
content: format!(
"Toolbox '{}' timed out after {TOOLBOX_TIMEOUT_SECS}s",
tb.name
),
is_error: true,
},
}
}
async fn exec_edit(&self, args: &Value) -> ToolResult {
let path_str = args.get("file_path").and_then(|v| v.as_str()).unwrap_or("");
let old_string = args
.get("old_string")
.and_then(|v| v.as_str())
.unwrap_or("");
let new_string = args
.get("new_string")
.and_then(|v| v.as_str())
.unwrap_or("");
if path_str.is_empty() {
return ToolResult {
content: "Error: 'file_path' parameter is required".to_string(),
is_error: true,
};
}
if old_string.is_empty() {
return ToolResult {
content: "Error: 'old_string' parameter is required".to_string(),
is_error: true,
};
}
let path = self.resolve_path(path_str);
let path = path.as_path();
if kernex_sandbox::is_write_blocked(path, &self.data_dir) {
return ToolResult {
content: format!("Write denied: {} is a protected path", path.display(),),
is_error: true,
};
}
debug!("tool/edit: {}", path.display());
let content = match tokio::fs::read_to_string(path).await {
Ok(c) => c,
Err(e) => {
return ToolResult {
content: format!("Error reading {}: {e}", path.display()),
is_error: true,
}
}
};
let count = content.matches(old_string).count();
if count == 0 {
return ToolResult {
content: "Error: old_string not found in file".to_string(),
is_error: true,
};
}
let new_content = content.replacen(old_string, new_string, 1);
match tokio::fs::write(path, &new_content).await {
Ok(()) => ToolResult {
content: format!(
"Edited {} ({count} occurrence(s) of pattern, replaced first)",
path.display()
),
is_error: false,
},
Err(e) => ToolResult {
content: format!("Error writing {}: {e}", path.display()),
is_error: true,
},
}
}
}
fn normalize_path(path: &Path) -> PathBuf {
use std::path::Component;
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
Component::ParentDir => {
normalized.pop();
}
Component::CurDir => {}
other => normalized.push(other),
}
}
normalized
}
fn truncate_output(s: &str, max_bytes: usize) -> String {
if s.len() <= max_bytes {
s.to_string()
} else {
let boundary = s.floor_char_boundary(max_bytes);
let truncated = &s[..boundary];
format!(
"{truncated}\n\n... (output truncated: {} total bytes, showing first {boundary})",
s.len()
)
}
}
pub fn builtin_tool_defs() -> Vec<ToolDef> {
vec![
ToolDef {
name: "bash".to_string(),
description: "Execute a bash command and return its output.".to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The bash command to execute"
}
},
"required": ["command"]
}),
},
ToolDef {
name: "read".to_string(),
description: "Read the contents of a file.".to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Absolute path to the file to read"
}
},
"required": ["file_path"]
}),
},
ToolDef {
name: "write".to_string(),
description: "Write content to a file (creates or overwrites).".to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Absolute path to the file to write"
},
"content": {
"type": "string",
"description": "The content to write"
}
},
"required": ["file_path", "content"]
}),
},
ToolDef {
name: "edit".to_string(),
description:
"Edit a file by replacing the first occurrence of old_string with new_string."
.to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Absolute path to the file to edit"
},
"old_string": {
"type": "string",
"description": "The exact string to find and replace"
},
"new_string": {
"type": "string",
"description": "The replacement string"
}
},
"required": ["file_path", "old_string", "new_string"]
}),
},
]
}
pub(crate) fn build_response(
text: String,
provider_name: &str,
total_tokens: u64,
elapsed_ms: u64,
model: Option<String>,
) -> Response {
Response {
text,
metadata: CompletionMeta {
provider_used: provider_name.to_string(),
tokens_used: if total_tokens > 0 {
Some(total_tokens)
} else {
None
},
processing_time_ms: elapsed_ms,
model,
session_id: None,
},
}
}
pub(crate) fn tools_enabled(context: &kernex_core::context::Context) -> bool {
context
.allowed_tools
.as_ref()
.map(|t| !t.is_empty())
.unwrap_or(true)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_builtin_tool_defs_count() {
let defs = builtin_tool_defs();
assert_eq!(defs.len(), 4);
let names: Vec<&str> = defs.iter().map(|d| d.name.as_str()).collect();
assert!(names.contains(&"bash"));
assert!(names.contains(&"read"));
assert!(names.contains(&"write"));
assert!(names.contains(&"edit"));
}
#[test]
fn test_tool_def_serialization() {
let defs = builtin_tool_defs();
for def in &defs {
let json = serde_json::to_value(def).unwrap();
assert!(json.get("name").is_some());
assert!(json.get("description").is_some());
assert!(json.get("parameters").is_some());
}
}
#[test]
fn test_truncate_output_short() {
let s = "hello world";
assert_eq!(truncate_output(s, 100), "hello world");
}
#[test]
fn test_truncate_output_exact() {
let s = "abcde";
assert_eq!(truncate_output(s, 5), "abcde");
}
#[test]
fn test_truncate_output_long() {
let s = "a".repeat(100);
let result = truncate_output(&s, 50);
assert!(result.starts_with(&"a".repeat(50)));
assert!(result.contains("output truncated"));
assert!(result.contains("100 total bytes"));
}
#[tokio::test]
async fn test_exec_bash_empty_command() {
let executor = ToolExecutor::new(PathBuf::from("/tmp"));
let result = executor.exec_bash(&serde_json::json!({})).await;
assert!(result.is_error);
assert!(result.content.contains("required"));
}
#[tokio::test]
async fn test_exec_bash_echo() {
let executor = ToolExecutor::new(PathBuf::from("/tmp"));
let result = executor
.exec_bash(&serde_json::json!({"command": "echo hello"}))
.await;
assert!(!result.is_error);
assert!(result.content.contains("hello"));
}
#[tokio::test]
async fn test_exec_read_nonexistent() {
let executor = ToolExecutor::new(PathBuf::from("/tmp"));
let result = executor
.exec_read(&serde_json::json!({"file_path": "/tmp/kernex_test_nonexistent_xyz"}))
.await;
assert!(result.is_error);
}
#[tokio::test]
async fn test_exec_write_and_read() {
let executor = ToolExecutor::new(PathBuf::from("/tmp"));
let path = "/tmp/kernex_tool_test_write.txt";
let write_result = executor
.exec_write(&serde_json::json!({"file_path": path, "content": "test content"}))
.await;
assert!(!write_result.is_error);
let read_result = executor
.exec_read(&serde_json::json!({"file_path": path}))
.await;
assert!(!read_result.is_error);
assert_eq!(read_result.content, "test content");
let _ = tokio::fs::remove_file(path).await;
}
#[tokio::test]
async fn test_exec_edit() {
let executor = ToolExecutor::new(PathBuf::from("/tmp"));
let path = "/tmp/kernex_tool_test_edit.txt";
tokio::fs::write(path, "hello world").await.unwrap();
let result = executor
.exec_edit(&serde_json::json!({
"file_path": path,
"old_string": "world",
"new_string": "kernex"
}))
.await;
assert!(!result.is_error);
let content = tokio::fs::read_to_string(path).await.unwrap();
assert_eq!(content, "hello kernex");
let _ = tokio::fs::remove_file(path).await;
}
#[tokio::test]
async fn test_exec_read_denied_protected_path() {
let executor = ToolExecutor::new(PathBuf::from("/home/user/.kernex/workspace"));
let result = executor
.exec_read(&serde_json::json!({"file_path": "/home/user/.kernex/data/memory.db"}))
.await;
assert!(result.is_error);
assert!(result.content.contains("denied"));
}
#[tokio::test]
async fn test_exec_read_denied_config() {
let executor = ToolExecutor::new(PathBuf::from("/home/user/.kernex/workspace"));
let result = executor
.exec_read(&serde_json::json!({"file_path": "/home/user/.kernex/config.toml"}))
.await;
assert!(result.is_error);
assert!(result.content.contains("denied"));
}
#[tokio::test]
async fn test_exec_write_denied_protected_path() {
let executor = ToolExecutor::new(PathBuf::from("/home/user/.kernex/workspace"));
let result = executor
.exec_write(&serde_json::json!({"file_path": "/home/user/.kernex/data/memory.db", "content": "x"}))
.await;
assert!(result.is_error);
assert!(result.content.contains("denied"));
}
#[test]
fn test_tool_executor_mcp_tool_map_routing() {
let executor = ToolExecutor::new(PathBuf::from("/tmp"));
assert!(executor.mcp_tool_map.is_empty());
assert!(executor.mcp_clients.is_empty());
}
#[tokio::test]
async fn test_execute_unknown_tool() {
let mut executor = ToolExecutor::new(PathBuf::from("/tmp"));
let result = executor
.execute("nonexistent_tool", &serde_json::json!({}))
.await;
assert!(result.is_error);
assert!(result.content.contains("Unknown tool"));
}
#[test]
fn test_register_toolboxes() {
let mut executor = ToolExecutor::new(PathBuf::from("/tmp"));
let toolboxes = vec![Toolbox {
name: "lint".into(),
description: "Run linter.".into(),
parameters: serde_json::json!({"type": "object"}),
command: "bash".into(),
args: vec!["lint.sh".into()],
env: std::collections::HashMap::new(),
}];
executor.register_toolboxes(&toolboxes);
assert!(executor.toolboxes.contains_key("lint"));
}
#[test]
fn test_all_tool_defs_includes_toolboxes() {
let mut executor = ToolExecutor::new(PathBuf::from("/tmp"));
let toolboxes = vec![Toolbox {
name: "lint".into(),
description: "Run linter.".into(),
parameters: serde_json::json!({"type": "object", "properties": {"file": {"type": "string"}}}),
command: "bash".into(),
args: vec!["lint.sh".into()],
env: std::collections::HashMap::new(),
}];
executor.register_toolboxes(&toolboxes);
let defs = executor.all_tool_defs();
assert!(defs.iter().any(|d| d.name == "lint"));
assert_eq!(defs.len(), 5); }
#[tokio::test]
async fn test_exec_toolbox_echo() {
let mut executor = ToolExecutor::new(PathBuf::from("/tmp"));
let tb = Toolbox {
name: "greet".into(),
description: "Echo greeting.".into(),
parameters: serde_json::json!({"type": "object"}),
command: "echo".into(),
args: vec!["hello from toolbox".into()],
env: std::collections::HashMap::new(),
};
executor.register_toolboxes(&[tb]);
let result = executor.execute("greet", &serde_json::json!({})).await;
assert!(!result.is_error);
assert!(result.content.contains("hello from toolbox"));
}
#[tokio::test]
async fn test_exec_toolbox_receives_stdin_json() {
let mut executor = ToolExecutor::new(PathBuf::from("/tmp"));
let tb = Toolbox {
name: "cat-input".into(),
description: "Cat stdin.".into(),
parameters: serde_json::json!({"type": "object"}),
command: "cat".into(),
args: Vec::new(),
env: std::collections::HashMap::new(),
};
executor.register_toolboxes(&[tb]);
let result = executor
.execute("cat-input", &serde_json::json!({"file": "test.rs"}))
.await;
assert!(!result.is_error);
assert!(result.content.contains("test.rs"));
}
#[tokio::test]
async fn test_exec_toolbox_nonzero_exit() {
let mut executor = ToolExecutor::new(PathBuf::from("/tmp"));
let tb = Toolbox {
name: "fail".into(),
description: "Always fails.".into(),
parameters: serde_json::json!({"type": "object"}),
command: "bash".into(),
args: vec!["-c".into(), "echo error >&2; exit 1".into()],
env: std::collections::HashMap::new(),
};
executor.register_toolboxes(&[tb]);
let result = executor.execute("fail", &serde_json::json!({})).await;
assert!(result.is_error);
assert!(result.content.contains("error"));
}
#[tokio::test]
async fn test_exec_toolbox_with_env() {
let mut executor = ToolExecutor::new(PathBuf::from("/tmp"));
let mut env = std::collections::HashMap::new();
env.insert("GREETING".into(), "hola".into());
let tb = Toolbox {
name: "env-test".into(),
description: "Print env var.".into(),
parameters: serde_json::json!({"type": "object"}),
command: "bash".into(),
args: vec!["-c".into(), "echo $GREETING".into()],
env,
};
executor.register_toolboxes(&[tb]);
let result = executor.execute("env-test", &serde_json::json!({})).await;
assert!(!result.is_error);
assert!(result.content.contains("hola"));
}
#[tokio::test]
async fn test_exec_toolbox_spawn_failure() {
let executor = ToolExecutor::new(PathBuf::from("/tmp"));
let tb = Toolbox {
name: "bad".into(),
description: "Bad command.".into(),
parameters: serde_json::json!({"type": "object"}),
command: "__nonexistent_cmd_xyz__".into(),
args: Vec::new(),
env: std::collections::HashMap::new(),
};
let result = executor.exec_toolbox(&tb, &serde_json::json!({})).await;
assert!(result.is_error);
}
#[test]
fn test_resolve_path_absolute() {
let executor = ToolExecutor::new(PathBuf::from("/home/user/.kernex/workspace"));
let resolved = executor.resolve_path("/tmp/test.txt");
assert_eq!(resolved, PathBuf::from("/tmp/test.txt"));
}
#[test]
fn test_resolve_path_relative() {
let executor = ToolExecutor::new(PathBuf::from("/home/user/.kernex/workspace"));
let resolved = executor.resolve_path("test.txt");
assert_eq!(
resolved,
PathBuf::from("/home/user/.kernex/workspace/test.txt")
);
}
#[test]
fn test_resolve_path_traversal_normalized() {
let executor = ToolExecutor::new(PathBuf::from("/home/user/.kernex/workspace"));
let resolved = executor.resolve_path("../data/memory.db");
assert_eq!(resolved, PathBuf::from("/home/user/.kernex/data/memory.db"));
let resolved2 = executor.resolve_path("../../data/memory.db");
assert_eq!(resolved2, PathBuf::from("/home/user/data/memory.db"));
}
#[tokio::test]
async fn test_exec_read_denied_relative_traversal() {
let executor = ToolExecutor::new(PathBuf::from("/home/user/.kernex/workspace"));
let result = executor
.exec_read(&serde_json::json!({"file_path": "../data/memory.db"}))
.await;
assert!(result.is_error);
assert!(result.content.contains("denied"));
}
#[tokio::test]
async fn test_exec_write_denied_config_toml() {
let executor = ToolExecutor::new(PathBuf::from("/home/user/.kernex/workspace"));
let result = executor
.exec_write(
&serde_json::json!({"file_path": "/home/user/.kernex/config.toml", "content": "x"}),
)
.await;
assert!(result.is_error);
assert!(result.content.contains("denied"));
}
#[test]
fn test_with_config_path() {
let executor = ToolExecutor::new(PathBuf::from("/tmp"))
.with_config_path(PathBuf::from("/opt/kernex/config.toml"));
assert_eq!(
executor.config_path,
Some(PathBuf::from("/opt/kernex/config.toml"))
);
}
#[test]
fn test_truncate_output_multibyte_boundary() {
let s = "\u{041f}\u{0440}\u{0438}\u{0432}\u{0435}\u{0442} \u{043c}\u{0438}\u{0440}!";
let result = truncate_output(s, 5);
assert!(!result.is_empty());
}
#[test]
fn test_truncate_output_emoji_boundary() {
let s = "Hello \u{1f30d} World";
let result = truncate_output(s, 8);
assert!(!result.is_empty());
}
}