use std::collections::HashMap;
use opendev_tools_core::{BaseTool, ToolContext};
use opendev_tools_impl::{BashTool, FileEditTool, FileReadTool, FileWriteTool, GrepTool};
use tempfile::TempDir;
fn make_args(pairs: &[(&str, serde_json::Value)]) -> HashMap<String, serde_json::Value> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.clone()))
.collect()
}
#[tokio::test]
async fn bash_executes_command_and_captures_stdout() {
let tmp = TempDir::new().unwrap();
let tool = BashTool::new();
let ctx = ToolContext::new(tmp.path());
let args = make_args(&[("command", serde_json::json!("echo integration_test_marker"))]);
let result = tool.execute(args, &ctx).await;
assert!(result.success, "bash should succeed: {:?}", result.error);
assert!(
result
.output
.as_ref()
.unwrap()
.contains("integration_test_marker"),
"stdout should contain marker"
);
}
#[tokio::test]
async fn bash_captures_stderr() {
let tmp = TempDir::new().unwrap();
let tool = BashTool::new();
let ctx = ToolContext::new(tmp.path());
let args = make_args(&[("command", serde_json::json!("echo err_marker >&2"))]);
let result = tool.execute(args, &ctx).await;
assert!(result.success);
let output = result.output.unwrap();
assert!(output.contains("err_marker"), "stderr should be captured");
}
#[tokio::test]
async fn bash_runs_in_correct_working_directory() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("sentinel.txt"), "found_it").unwrap();
let tool = BashTool::new();
let ctx = ToolContext::new(tmp.path());
let args = make_args(&[("command", serde_json::json!("cat sentinel.txt"))]);
let result = tool.execute(args, &ctx).await;
assert!(result.success);
assert!(result.output.unwrap().contains("found_it"));
}
#[tokio::test]
async fn bash_timeout_kills_long_running_command() {
let tool = BashTool::new();
let ctx = ToolContext::new("/tmp");
let args = make_args(&[
("command", serde_json::json!("sleep 30")),
("timeout", serde_json::json!(1)),
]);
let result = tool.execute(args, &ctx).await;
assert!(!result.success, "timed-out command should fail");
assert!(
result.error.as_ref().unwrap().contains("timed out"),
"error should mention timeout"
);
}
#[tokio::test]
async fn bash_nonzero_exit_code_is_failure() {
let tool = BashTool::new();
let ctx = ToolContext::new("/tmp");
let args = make_args(&[("command", serde_json::json!("exit 7"))]);
let result = tool.execute(args, &ctx).await;
assert!(!result.success);
assert_eq!(
result.metadata.get("exit_code"),
Some(&serde_json::json!(7))
);
}
#[tokio::test]
async fn bash_blocks_dangerous_rm_rf() {
let tool = BashTool::new();
let ctx = ToolContext::new("/tmp");
let args = make_args(&[("command", serde_json::json!("rm -rf /"))]);
let result = tool.execute(args, &ctx).await;
assert!(!result.success);
assert!(result.error.unwrap().contains("Blocked dangerous"));
}
#[tokio::test]
async fn bash_pipes_work() {
let tool = BashTool::new();
let ctx = ToolContext::new("/tmp");
let args = make_args(&[("command", serde_json::json!("echo 'a\nb\nc' | wc -l"))]);
let result = tool.execute(args, &ctx).await;
assert!(result.success);
let output = result.output.unwrap().trim().to_string();
assert!(
output.contains("3"),
"pipe should count 3 lines, got: {output}"
);
}
#[tokio::test]
async fn file_write_read_edit_lifecycle() {
let tmp = TempDir::new().unwrap();
let file_path = tmp.path().join("lifecycle.txt");
let file_str = file_path.to_str().unwrap();
let write_tool = FileWriteTool;
let ctx = ToolContext::new(tmp.path());
let write_args = make_args(&[
("file_path", serde_json::json!(file_str)),
(
"content",
serde_json::json!("line one\nline two\nline three\n"),
),
]);
let result = write_tool.execute(write_args, &ctx).await;
assert!(result.success, "write should succeed: {:?}", result.error);
assert_eq!(result.metadata["lines"], serde_json::json!(3));
let read_tool = FileReadTool;
let read_args = make_args(&[("file_path", serde_json::json!(file_str))]);
let result = read_tool.execute(read_args, &ctx).await;
assert!(result.success);
let output = result.output.unwrap();
assert!(output.contains("line one"));
assert!(output.contains("line two"));
assert!(output.contains("line three"));
assert_eq!(result.metadata["total_lines"], serde_json::json!(3));
let edit_tool = FileEditTool;
let edit_args = make_args(&[
("file_path", serde_json::json!(file_str)),
("old_string", serde_json::json!("line two")),
("new_string", serde_json::json!("line TWO")),
]);
let result = edit_tool.execute(edit_args, &ctx).await;
assert!(result.success, "edit should succeed: {:?}", result.error);
assert_eq!(result.metadata["replacements"], serde_json::json!(1));
let read_args = make_args(&[("file_path", serde_json::json!(file_str))]);
let result = read_tool.execute(read_args, &ctx).await;
assert!(result.success);
let output = result.output.unwrap();
assert!(output.contains("line TWO"), "edit should be visible");
assert!(!output.contains("line two"), "old text should be gone");
}
#[tokio::test]
async fn file_write_creates_nested_dirs() {
let tmp = TempDir::new().unwrap();
let nested = tmp.path().join("a").join("b").join("c").join("deep.txt");
let ctx = ToolContext::new(tmp.path());
let tool = FileWriteTool;
let args = make_args(&[
("file_path", serde_json::json!(nested.to_str().unwrap())),
("content", serde_json::json!("deep content")),
]);
let result = tool.execute(args, &ctx).await;
assert!(result.success);
assert_eq!(std::fs::read_to_string(&nested).unwrap(), "deep content");
}
#[tokio::test]
async fn file_edit_trailing_newline_handling() {
let tmp = TempDir::new().unwrap();
let file_path = tmp.path().join("trailing.txt");
std::fs::write(&file_path, "hello world\n").unwrap();
let tool = FileEditTool;
let ctx = ToolContext::new(tmp.path());
let args = make_args(&[
("file_path", serde_json::json!(file_path.to_str().unwrap())),
("old_string", serde_json::json!("hello world\n")),
("new_string", serde_json::json!("hello world")),
]);
let result = tool.execute(args, &ctx).await;
assert!(result.success, "trailing newline edit should succeed");
let content = std::fs::read_to_string(&file_path).unwrap();
assert_eq!(content, "hello world");
}
#[tokio::test]
async fn file_edit_replace_all_multiple_occurrences() {
let tmp = TempDir::new().unwrap();
let file_path = tmp.path().join("multi.txt");
std::fs::write(&file_path, "cat dog cat fish cat").unwrap();
let tool = FileEditTool;
let ctx = ToolContext::new(tmp.path());
let args = make_args(&[
("file_path", serde_json::json!(file_path.to_str().unwrap())),
("old_string", serde_json::json!("cat")),
("new_string", serde_json::json!("bird")),
("replace_all", serde_json::json!(true)),
]);
let result = tool.execute(args, &ctx).await;
assert!(result.success);
assert_eq!(result.metadata["replacements"], serde_json::json!(3));
let content = std::fs::read_to_string(&file_path).unwrap();
assert_eq!(content, "bird dog bird fish bird");
}
#[tokio::test]
async fn file_edit_rejects_ambiguous_match() {
let tmp = TempDir::new().unwrap();
let file_path = tmp.path().join("ambiguous.txt");
std::fs::write(&file_path, "aaa bbb aaa").unwrap();
let tool = FileEditTool;
let ctx = ToolContext::new(tmp.path());
let args = make_args(&[
("file_path", serde_json::json!(file_path.to_str().unwrap())),
("old_string", serde_json::json!("aaa")),
("new_string", serde_json::json!("ccc")),
]);
let result = tool.execute(args, &ctx).await;
assert!(!result.success, "ambiguous match should fail");
assert!(result.error.unwrap().contains("2 times"));
}
#[tokio::test]
async fn file_read_offset_and_limit() {
let tmp = TempDir::new().unwrap();
let file_path = tmp.path().join("lines.txt");
let content: String = (1..=20).map(|i| format!("line {i}\n")).collect();
std::fs::write(&file_path, &content).unwrap();
let tool = FileReadTool;
let ctx = ToolContext::new(tmp.path());
let args = make_args(&[
("file_path", serde_json::json!(file_path.to_str().unwrap())),
("offset", serde_json::json!(5)),
("limit", serde_json::json!(3)),
]);
let result = tool.execute(args, &ctx).await;
assert!(result.success);
let output = result.output.unwrap();
assert!(output.contains("line 5"), "should start at line 5");
assert!(output.contains("line 7"), "should include line 7");
assert!(!output.contains("line 8"), "should not include line 8");
assert_eq!(result.metadata["lines_shown"], serde_json::json!(3));
}
#[tokio::test]
async fn file_read_detects_binary() {
let tmp = TempDir::new().unwrap();
let file_path = tmp.path().join("binary.bin");
std::fs::write(&file_path, &[0u8, 1, 2, 3, 0, 5, 6, 7]).unwrap();
let tool = FileReadTool;
let ctx = ToolContext::new(tmp.path());
let args = make_args(&[("file_path", serde_json::json!(file_path.to_str().unwrap()))]);
let result = tool.execute(args, &ctx).await;
assert!(!result.success);
assert!(result.error.unwrap().contains("Binary"));
}
#[tokio::test]
async fn search_finds_pattern_across_files() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("a.rs"), "fn main() {\n todo!()\n}\n").unwrap();
std::fs::write(tmp.path().join("b.rs"), "fn helper() {}\n").unwrap();
std::fs::write(tmp.path().join("c.txt"), "no functions here\n").unwrap();
let tool = GrepTool;
let ctx = ToolContext::new(tmp.path());
let args = make_args(&[("pattern", serde_json::json!("fn \\w+"))]);
let result = tool.execute(args, &ctx).await;
assert!(result.success);
let output = result.output.unwrap();
assert!(output.contains("fn main"), "should find fn main");
assert!(output.contains("fn helper"), "should find fn helper");
}
#[tokio::test]
async fn search_respects_glob_filter() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("code.rs"), "fn foo() {}\n").unwrap();
std::fs::write(tmp.path().join("readme.md"), "fn bar() {}\n").unwrap();
let tool = GrepTool;
let ctx = ToolContext::new(tmp.path());
let args = make_args(&[
("pattern", serde_json::json!("fn ")),
("glob", serde_json::json!("*.rs")),
]);
let result = tool.execute(args, &ctx).await;
assert!(result.success);
let output = result.output.unwrap();
assert!(output.contains("foo"), "should find in .rs file");
assert!(!output.contains("bar"), "should not find in .md file");
}
#[tokio::test]
async fn search_no_matches_is_not_error() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("file.txt"), "hello world\n").unwrap();
let tool = GrepTool;
let ctx = ToolContext::new(tmp.path());
let args = make_args(&[("pattern", serde_json::json!("nonexistent_pattern_xyz"))]);
let result = tool.execute(args, &ctx).await;
assert!(result.success, "no-match should still be success");
assert!(result.output.unwrap().contains("No matches"));
}
#[tokio::test]
async fn search_invalid_regex_becomes_fixed_string() {
let tool = GrepTool;
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path().canonicalize().unwrap();
std::fs::write(dir.join("test.txt"), "[unclosed is literal text\n").unwrap();
let ctx = ToolContext::new(dir.to_str().unwrap());
let args = make_args(&[("pattern", serde_json::json!("[unclosed"))]);
let result = tool.execute(args, &ctx).await;
assert!(
result.success,
"invalid regex should auto-promote to fixed-string search"
);
}
#[test]
fn git_force_push_detected_as_dangerous() {
use opendev_tools_impl::bash::is_dangerous_command;
assert!(is_dangerous_command("git push --force origin main"));
assert!(is_dangerous_command("git push -f origin main"));
assert!(is_dangerous_command("git reset --hard HEAD~1"));
assert!(is_dangerous_command("git clean -fd"));
assert!(is_dangerous_command("git checkout -- ."));
assert!(is_dangerous_command("git branch -D feature"));
assert!(!is_dangerous_command("git status"));
assert!(!is_dangerous_command("git log"));
assert!(!is_dangerous_command("git diff"));
assert!(!is_dangerous_command("git push origin main"));
assert!(!is_dangerous_command(
"git push --force-with-lease origin main"
));
}
#[test]
fn fuzzy_match_passes_integration() {
use opendev_tools_impl::edit_replacers::find_match;
let original = "fn main() {\n println!(\"hello\");\n}";
let result = find_match(original, "println!(\"hello\");").unwrap();
assert_eq!(result.pass_name, "simple");
let original = "fn foo() {\n let x = 1;\n let y = 2;\n}";
let result = find_match(original, "let x = 1;\nlet y = 2;").unwrap();
assert_eq!(result.pass_name, "line_trimmed");
assert_eq!(result.actual, " let x = 1;\n let y = 2;");
let original = "let x = 1;";
let result = find_match(original, "let x = 1;").unwrap();
assert_eq!(result.pass_name, "whitespace_normalized");
assert_eq!(result.actual, "let x = 1;");
let original = "let s = \"hello\nworld\";";
let result = find_match(original, "let s = \"hello\\nworld\";").unwrap();
assert_eq!(result.pass_name, "escape_normalized");
let result = opendev_tools_impl::edit_replacers::find_match("abc xyz def", " xyz ").unwrap();
assert!(result.actual.contains("xyz"));
let result = find_match("hello world", "completely different text that is nowhere");
assert!(result.is_none());
}
#[test]
fn unified_diff_output_format() {
use opendev_tools_impl::edit_replacers::unified_diff;
let original = "line1\nline2\nline3\nline4\n";
let modified = "line1\nline2_changed\nline3\nline4\nline5\n";
let diff = unified_diff("test.rs", original, modified, 3);
assert!(diff.contains("--- a/test.rs"));
assert!(diff.contains("+++ b/test.rs"));
assert!(diff.contains("-line2"));
assert!(diff.contains("+line2_changed"));
assert!(diff.contains("+line5"));
}
#[test]
fn unified_diff_no_changes_returns_empty() {
use opendev_tools_impl::edit_replacers::unified_diff;
let text = "same\ncontent\n";
let diff = unified_diff("test.rs", text, text, 3);
assert!(diff.is_empty());
}
#[test]
fn find_occurrence_positions_multiple() {
use opendev_tools_impl::edit_replacers::find_occurrence_positions;
let content = "foo\nbar\nfoo\nbaz\nfoo";
let positions = find_occurrence_positions(content, "foo");
assert_eq!(positions, vec![1, 3, 5]);
let positions = find_occurrence_positions(content, "xyz");
assert!(positions.is_empty());
}
#[test]
fn fuzzy_match_normalizes_crlf() {
use opendev_tools_impl::edit_replacers::find_match;
let original = "line1\r\nline2\r\nline3";
let result = find_match(original, "line2").unwrap();
assert_eq!(result.pass_name, "simple");
assert_eq!(result.actual, "line2");
}
#[tokio::test]
async fn file_list_shows_directory_contents() {
use opendev_tools_impl::FileListTool;
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("file1.rs"), "fn main() {}").unwrap();
std::fs::write(tmp.path().join("file2.txt"), "hello").unwrap();
std::fs::create_dir_all(tmp.path().join("subdir")).unwrap();
std::fs::write(tmp.path().join("subdir").join("nested.rs"), "mod nested;").unwrap();
let tool = FileListTool;
let ctx = ToolContext::new(tmp.path());
let args = make_args(&[("pattern", serde_json::json!("**/*"))]);
let result = tool.execute(args, &ctx).await;
assert!(
result.success,
"file_list should succeed: {:?}",
result.error
);
let output = result.output.unwrap();
assert!(output.contains("file1.rs"));
assert!(output.contains("file2.txt"));
assert!(output.contains("nested.rs"));
}