use std::time::Duration;
use opi_agent::tool::{ExecutionMode, Tool, ToolResult};
use opi_coding_agent::tool::{BashTool, EditTool, PathPolicy, ReadTool, WriteTool};
use serde_json::json;
use tokio_util::sync::CancellationToken;
fn tool_result_text(result: &ToolResult) -> String {
result
.content
.iter()
.filter_map(|c| match c {
opi_ai::message::OutputContent::Text { text } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("")
}
fn details_string<'a>(details: &'a serde_json::Value, key: &str) -> &'a str {
details
.get(key)
.and_then(|value| value.as_str())
.unwrap_or_else(|| panic!("details should include string key '{key}'"))
}
#[tokio::test]
async fn read_tool_reads_file_content() {
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("hello.txt");
std::fs::write(&file_path, "Hello, world!").unwrap();
let tool = ReadTool::new(dir.path().to_path_buf());
let result = tool
.execute(
"c1",
json!({ "path": "hello.txt" }),
CancellationToken::new(),
None,
)
.await
.unwrap();
assert!(
!result.is_error,
"unexpected error: {}",
tool_result_text(&result)
);
let text = tool_result_text(&result);
assert!(
text.contains("Hello, world!"),
"should contain file content"
);
}
#[tokio::test]
async fn read_tool_reads_with_line_range() {
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("lines.txt");
std::fs::write(&file_path, "line1\nline2\nline3\nline4\nline5").unwrap();
let tool = ReadTool::new(dir.path().to_path_buf());
let result = tool
.execute(
"c2",
json!({ "path": "lines.txt", "offset": 2, "limit": 2 }),
CancellationToken::new(),
None,
)
.await
.unwrap();
assert!(!result.is_error);
let text = tool_result_text(&result);
assert!(text.contains("line2"), "should contain line2");
assert!(text.contains("line3"), "should contain line3");
assert!(!text.contains("line1"), "should not contain line1");
assert!(!text.contains("line4"), "should not contain line4");
}
#[tokio::test]
async fn read_tool_file_not_found() {
let dir = tempfile::tempdir().unwrap();
let tool = ReadTool::new(dir.path().to_path_buf());
let result = tool
.execute(
"c3",
json!({ "path": "nonexistent.txt" }),
CancellationToken::new(),
None,
)
.await
.unwrap();
assert!(result.is_error, "should be error for missing file");
}
#[tokio::test]
async fn read_tool_is_parallel() {
let tool = ReadTool::new(std::path::PathBuf::from("."));
assert_eq!(tool.execution_mode(), ExecutionMode::Parallel);
}
#[tokio::test]
async fn read_tool_reports_workspace_boundary() {
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("inside.txt");
std::fs::write(&file_path, "data").unwrap();
let tool = ReadTool::new(dir.path().to_path_buf());
let result = tool
.execute(
"c4",
json!({ "path": "inside.txt" }),
CancellationToken::new(),
None,
)
.await
.unwrap();
let text = tool_result_text(&result);
assert!(
text.contains("inside.txt"),
"result should reference the file path"
);
let details = result.details.expect("should have details");
assert_eq!(
details
.get("inside_workspace")
.and_then(|value| value.as_bool()),
Some(true)
);
assert!(details_string(&details, "resolved_path").ends_with("inside.txt"));
}
#[tokio::test]
async fn read_tool_allow_outside_policy_reads_absolute_outside_path() {
let workspace = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let outside_file = outside.path().join("outside.txt");
std::fs::write(&outside_file, "outside data").unwrap();
let tool = ReadTool::new_with_policy(
workspace.path().to_path_buf(),
PathPolicy::AllowOutsideWorkspace,
);
let result = tool
.execute(
"outside-read-allow",
json!({ "path": outside_file }),
CancellationToken::new(),
None,
)
.await
.unwrap();
assert!(
!result.is_error,
"unexpected error: {}",
tool_result_text(&result)
);
assert!(tool_result_text(&result).contains("outside data"));
let details = result.details.expect("should have details");
assert_eq!(
details
.get("inside_workspace")
.and_then(|value| value.as_bool()),
Some(false)
);
assert_eq!(
details_string(&details, "resolved_path"),
std::fs::canonicalize(&outside_file)
.unwrap()
.to_string_lossy()
);
}
#[tokio::test]
async fn write_tool_creates_file() {
let dir = tempfile::tempdir().unwrap();
let tool = WriteTool::new(dir.path().to_path_buf());
let result = tool
.execute(
"c5",
json!({ "path": "new.txt", "content": "Hello!" }),
CancellationToken::new(),
None,
)
.await
.unwrap();
assert!(
!result.is_error,
"unexpected error: {}",
tool_result_text(&result)
);
let written = std::fs::read_to_string(dir.path().join("new.txt")).unwrap();
assert_eq!(written, "Hello!");
}
#[tokio::test]
async fn write_tool_replaces_existing_file() {
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("existing.txt");
std::fs::write(&file_path, "old content").unwrap();
let tool = WriteTool::new(dir.path().to_path_buf());
let result = tool
.execute(
"c6",
json!({ "path": "existing.txt", "content": "new content" }),
CancellationToken::new(),
None,
)
.await
.unwrap();
assert!(!result.is_error);
let written = std::fs::read_to_string(file_path).unwrap();
assert_eq!(written, "new content");
}
#[tokio::test]
async fn write_tool_is_sequential() {
let tool = WriteTool::new(std::path::PathBuf::from("."));
assert_eq!(tool.execution_mode(), ExecutionMode::Sequential);
}
#[tokio::test]
async fn write_tool_safety_context_in_details() {
let dir = tempfile::tempdir().unwrap();
let tool = WriteTool::new(dir.path().to_path_buf());
let result = tool
.execute(
"c7",
json!({ "path": "safe.txt", "content": "data" }),
CancellationToken::new(),
None,
)
.await
.unwrap();
let details = result.details.expect("should have details");
assert!(
details.get("workspace_root").is_some(),
"details should include workspace_root"
);
assert!(details.get("path").is_some(), "details should include path");
assert_eq!(
details
.get("inside_workspace")
.and_then(|value| value.as_bool()),
Some(true)
);
assert!(details_string(&details, "resolved_path").ends_with("safe.txt"));
}
#[tokio::test]
async fn edit_tool_exact_string_replacement() {
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("edit.txt");
std::fs::write(&file_path, "Hello, world!").unwrap();
let tool = EditTool::new(dir.path().to_path_buf());
let result = tool
.execute(
"c8",
json!({
"path": "edit.txt",
"old_string": "world",
"new_string": "Rust"
}),
CancellationToken::new(),
None,
)
.await
.unwrap();
assert!(
!result.is_error,
"unexpected error: {}",
tool_result_text(&result)
);
let content = std::fs::read_to_string(&file_path).unwrap();
assert_eq!(content, "Hello, Rust!");
}
#[tokio::test]
async fn edit_tool_old_string_not_found() {
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("edit2.txt");
std::fs::write(&file_path, "Hello").unwrap();
let tool = EditTool::new(dir.path().to_path_buf());
let result = tool
.execute(
"c9",
json!({
"path": "edit2.txt",
"old_string": "not present",
"new_string": "replacement"
}),
CancellationToken::new(),
None,
)
.await
.unwrap();
assert!(result.is_error, "should be error when old_string not found");
}
#[tokio::test]
async fn edit_tool_is_sequential() {
let tool = EditTool::new(std::path::PathBuf::from("."));
assert_eq!(tool.execution_mode(), ExecutionMode::Sequential);
}
#[tokio::test]
async fn edit_tool_safety_context_in_details() {
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("safe_edit.txt");
std::fs::write(&file_path, "content").unwrap();
let tool = EditTool::new(dir.path().to_path_buf());
let result = tool
.execute(
"c10",
json!({
"path": "safe_edit.txt",
"old_string": "content",
"new_string": "changed"
}),
CancellationToken::new(),
None,
)
.await
.unwrap();
let details = result.details.expect("should have details");
assert!(details.get("workspace_root").is_some());
assert!(details.get("path").is_some());
assert_eq!(
details
.get("inside_workspace")
.and_then(|value| value.as_bool()),
Some(true)
);
assert!(details_string(&details, "resolved_path").ends_with("safe_edit.txt"));
assert!(details.get("before").is_some());
assert!(details.get("after").is_some());
}
#[tokio::test]
async fn bash_tool_runs_command() {
let dir = tempfile::tempdir().unwrap();
let tool = BashTool::new(dir.path().to_path_buf());
let cmd = "echo hello";
let result = tool
.execute(
"c11",
json!({ "command": cmd }),
CancellationToken::new(),
None,
)
.await
.unwrap();
assert!(
!result.is_error,
"unexpected error: {}",
tool_result_text(&result)
);
let text = tool_result_text(&result);
assert!(text.contains("hello"), "output should contain 'hello'");
}
#[tokio::test]
async fn bash_tool_nonzero_exit_is_error() {
let dir = tempfile::tempdir().unwrap();
let tool = BashTool::new(dir.path().to_path_buf());
let cmd = if cfg!(windows) {
"cmd /C exit 1"
} else {
"exit 1"
};
let result = tool
.execute(
"c12",
json!({ "command": cmd }),
CancellationToken::new(),
None,
)
.await
.unwrap();
assert!(result.is_error, "non-zero exit should be error");
}
#[tokio::test]
async fn write_tool_rejects_path_outside_workspace() {
let dir = tempfile::tempdir().unwrap();
let tool = WriteTool::new(dir.path().to_path_buf());
let result = tool
.execute(
"escape-1",
json!({ "path": "../outside.txt", "content": "escaped" }),
CancellationToken::new(),
None,
)
.await
.unwrap();
assert!(result.is_error, "should reject path outside workspace");
let text = tool_result_text(&result);
assert!(
text.contains("outside the workspace"),
"error should mention workspace boundary, got: {text}"
);
}
#[tokio::test]
async fn write_tool_rejects_absolute_path_outside_workspace() {
let workspace = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let outside_file = outside.path().join("outside-write.txt");
let tool = WriteTool::new(workspace.path().to_path_buf());
let result = tool
.execute(
"escape-absolute-write",
json!({ "path": outside_file, "content": "escaped" }),
CancellationToken::new(),
None,
)
.await
.unwrap();
assert!(result.is_error, "should reject path outside workspace");
assert!(
tool_result_text(&result).contains("outside the workspace"),
"error should mention workspace boundary"
);
assert!(
!outside_file.exists(),
"write must not create files outside workspace"
);
}
#[tokio::test]
async fn write_tool_normalizes_parent_segment_after_missing_component() {
let workspace = tempfile::tempdir().unwrap();
let tool = WriteTool::new(workspace.path().to_path_buf());
let result = tool
.execute(
"normalize-missing-parent",
json!({ "path": "missing/../target.txt", "content": "normalized" }),
CancellationToken::new(),
None,
)
.await
.unwrap();
assert!(
!result.is_error,
"unexpected error: {}",
tool_result_text(&result)
);
assert_eq!(
std::fs::read_to_string(workspace.path().join("target.txt")).unwrap(),
"normalized"
);
assert!(
!workspace.path().join("missing/target.txt").exists(),
"path should normalize to workspace target, not missing/target"
);
}
#[tokio::test]
async fn write_tool_rejects_parent_escape_after_missing_component() {
let parent = tempfile::tempdir().unwrap();
let workspace = parent.path().join("workspace");
std::fs::create_dir(&workspace).unwrap();
let outside_file = parent.path().join("outside.txt");
let tool = WriteTool::new(workspace);
let result = tool
.execute(
"normalize-missing-parent-escape",
json!({ "path": "missing/../../outside.txt", "content": "escaped" }),
CancellationToken::new(),
None,
)
.await
.unwrap();
assert!(result.is_error, "should reject path outside workspace");
assert!(
tool_result_text(&result).contains("outside the workspace"),
"error should mention workspace boundary"
);
assert!(
!outside_file.exists(),
"write must not create files outside workspace"
);
}
#[tokio::test]
async fn edit_tool_rejects_path_outside_workspace() {
let dir = tempfile::tempdir().unwrap();
let tool = EditTool::new(dir.path().to_path_buf());
let result = tool
.execute(
"escape-2",
json!({
"path": "../escape.txt",
"old_string": "x",
"new_string": "y"
}),
CancellationToken::new(),
None,
)
.await
.unwrap();
assert!(result.is_error, "should reject path outside workspace");
let text = tool_result_text(&result);
assert!(
text.contains("outside the workspace"),
"error should mention workspace boundary, got: {text}"
);
}
#[tokio::test]
async fn edit_tool_rejects_absolute_path_outside_workspace() {
let workspace = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let outside_file = outside.path().join("outside-edit.txt");
std::fs::write(&outside_file, "before").unwrap();
let tool = EditTool::new(workspace.path().to_path_buf());
let result = tool
.execute(
"escape-absolute-edit",
json!({
"path": outside_file,
"old_string": "before",
"new_string": "after"
}),
CancellationToken::new(),
None,
)
.await
.unwrap();
assert!(result.is_error, "should reject path outside workspace");
assert!(
tool_result_text(&result).contains("outside the workspace"),
"error should mention workspace boundary"
);
assert_eq!(std::fs::read_to_string(&outside_file).unwrap(), "before");
}
#[tokio::test]
async fn read_tool_rejects_path_outside_workspace() {
let dir = tempfile::tempdir().unwrap();
let tool = ReadTool::new(dir.path().to_path_buf());
let result = tool
.execute(
"escape-3",
json!({ "path": "../etc/passwd" }),
CancellationToken::new(),
None,
)
.await
.unwrap();
assert!(result.is_error, "should reject path outside workspace");
let text = tool_result_text(&result);
assert!(
text.contains("outside the workspace"),
"error should mention workspace boundary, got: {text}"
);
}
#[tokio::test]
async fn read_tool_workspace_policy_rejects_absolute_outside_path() {
let workspace = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let outside_file = outside.path().join("outside-read.txt");
std::fs::write(&outside_file, "outside").unwrap();
let tool = ReadTool::new(workspace.path().to_path_buf());
let result = tool
.execute(
"escape-absolute-read",
json!({ "path": outside_file }),
CancellationToken::new(),
None,
)
.await
.unwrap();
assert!(result.is_error, "should reject path outside workspace");
assert!(
tool_result_text(&result).contains("outside the workspace"),
"error should mention workspace boundary"
);
}
#[tokio::test]
async fn bash_tool_timeout() {
let dir = tempfile::tempdir().unwrap();
let tool = BashTool::new(dir.path().to_path_buf());
let cmd = if cfg!(windows) {
"ping -n 30 127.0.0.1 >nul"
} else {
"sleep 30"
};
let result = tool
.execute(
"c13",
json!({ "command": cmd, "timeout_secs": 1 }),
CancellationToken::new(),
None,
)
.await
.unwrap();
assert!(result.is_error, "timeout should produce error");
let text = tool_result_text(&result);
assert!(
text.to_lowercase().contains("timeout") || text.to_lowercase().contains("timed out"),
"error should mention timeout: {text}"
);
}
#[tokio::test]
async fn bash_tool_cancellation() {
let dir = tempfile::tempdir().unwrap();
let tool = BashTool::new(dir.path().to_path_buf());
let token = CancellationToken::new();
let cmd = if cfg!(windows) {
"ping -n 30 127.0.0.1 >nul"
} else {
"sleep 30"
};
let handle = {
let token = token.clone();
tokio::spawn(async move {
tool.execute(
"c14",
json!({ "command": cmd, "timeout_secs": 60 }),
token,
None,
)
.await
})
};
tokio::time::sleep(Duration::from_millis(100)).await;
token.cancel();
let result = handle.await.unwrap().unwrap();
assert!(result.is_error, "cancelled process should be error");
}
#[tokio::test]
async fn bash_tool_cwd_reporting() {
let dir = tempfile::tempdir().unwrap();
let tool = BashTool::new(dir.path().to_path_buf());
let cmd = if cfg!(windows) { "cd" } else { "pwd" };
let result = tool
.execute(
"c15",
json!({ "command": cmd }),
CancellationToken::new(),
None,
)
.await
.unwrap();
assert!(!result.is_error);
let details = result.details.expect("should have details");
let cwd = details.get("cwd").unwrap().as_str().unwrap();
assert!(
cwd.contains(dir.path().file_name().unwrap().to_str().unwrap()),
"details.cwd should contain temp dir name, got: {cwd}"
);
}
#[tokio::test]
async fn bash_tool_is_sequential() {
let tool = BashTool::new(std::path::PathBuf::from("."));
assert_eq!(tool.execution_mode(), ExecutionMode::Sequential);
}
#[tokio::test]
async fn bash_tool_safety_context_in_details() {
let dir = tempfile::tempdir().unwrap();
let tool = BashTool::new(dir.path().to_path_buf());
let result = tool
.execute(
"c16",
json!({ "command": "echo test" }),
CancellationToken::new(),
None,
)
.await
.unwrap();
let details = result.details.expect("should have details");
assert!(
details.get("command").is_some(),
"details should include command"
);
assert!(details.get("cwd").is_some(), "details should include cwd");
}
#[tokio::test]
async fn bash_tool_env_inheritance_reporting() {
let dir = tempfile::tempdir().unwrap();
let tool = BashTool::new(dir.path().to_path_buf());
let cmd = if cfg!(windows) { "set" } else { "env" };
let result = tool
.execute(
"c17",
json!({ "command": cmd }),
CancellationToken::new(),
None,
)
.await
.unwrap();
assert!(!result.is_error);
let text = tool_result_text(&result);
assert!(!text.is_empty(), "should have env output");
}
fn create_dir_link(link_path: &std::path::Path, target: &std::path::Path) -> bool {
#[cfg(windows)]
{
let output = std::process::Command::new("cmd")
.args([
"/C",
"mklink",
"/J",
&link_path.to_string_lossy(),
&target.to_string_lossy(),
])
.output();
match output {
Ok(o) => o.status.success(),
Err(_) => false,
}
}
#[cfg(not(windows))]
{
std::os::unix::fs::symlink(target, link_path).is_ok()
}
}
#[tokio::test]
async fn write_tool_rejects_symlink_escape_via_new_subpath() {
let workspace = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let link = workspace.path().join("link");
if !create_dir_link(&link, outside.path()) {
eprintln!("skipping: could not create directory link");
return;
}
let tool = WriteTool::new(workspace.path().to_path_buf());
let result = tool
.execute(
"symlink-escape-1",
json!({ "path": "link/new/file.txt", "content": "escaped" }),
CancellationToken::new(),
None,
)
.await
.unwrap();
assert!(
result.is_error,
"should reject symlink escape, got: {:?}",
tool_result_text(&result)
);
let text = tool_result_text(&result);
assert!(
text.contains("outside the workspace"),
"error should mention workspace boundary, got: {text}"
);
assert!(
!outside.path().join("new/file.txt").exists(),
"file must not exist outside workspace"
);
}
#[tokio::test]
async fn read_tool_rejects_symlink_escape_via_new_subpath() {
let workspace = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let link = workspace.path().join("link");
if !create_dir_link(&link, outside.path()) {
eprintln!("skipping: could not create directory link");
return;
}
let tool = ReadTool::new(workspace.path().to_path_buf());
let result = tool
.execute(
"symlink-escape-2",
json!({ "path": "link/new/file.txt" }),
CancellationToken::new(),
None,
)
.await
.unwrap();
assert!(
result.is_error,
"should reject symlink escape, got: {:?}",
tool_result_text(&result)
);
let text = tool_result_text(&result);
assert!(
text.contains("outside the workspace"),
"error should mention workspace boundary, got: {text}"
);
}
#[tokio::test]
async fn edit_tool_rejects_symlink_escape_via_new_subpath() {
let workspace = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let link = workspace.path().join("link");
if !create_dir_link(&link, outside.path()) {
eprintln!("skipping: could not create directory link");
return;
}
let tool = EditTool::new(workspace.path().to_path_buf());
let result = tool
.execute(
"symlink-escape-3",
json!({
"path": "link/new/file.txt",
"old_string": "x",
"new_string": "y"
}),
CancellationToken::new(),
None,
)
.await
.unwrap();
assert!(
result.is_error,
"should reject symlink escape, got: {:?}",
tool_result_text(&result)
);
let text = tool_result_text(&result);
assert!(
text.contains("outside the workspace"),
"error should mention workspace boundary, got: {text}"
);
}
#[test]
fn read_tool_has_valid_definition() {
let tool = ReadTool::new(std::path::PathBuf::from("."));
let def = tool.definition();
assert_eq!(def.name, "read");
assert!(!def.description.is_empty());
assert!(def.input_schema.is_object());
}
#[test]
fn write_tool_has_valid_definition() {
let tool = WriteTool::new(std::path::PathBuf::from("."));
let def = tool.definition();
assert_eq!(def.name, "write");
assert!(!def.description.is_empty());
}
#[test]
fn edit_tool_has_valid_definition() {
let tool = EditTool::new(std::path::PathBuf::from("."));
let def = tool.definition();
assert_eq!(def.name, "edit");
assert!(!def.description.is_empty());
}
#[test]
fn bash_tool_has_valid_definition() {
let tool = BashTool::new(std::path::PathBuf::from("."));
let def = tool.definition();
assert_eq!(def.name, "bash");
assert!(!def.description.is_empty());
}