use super::*;
use roboticus_core::config::SkillsConfig;
use uuid::Uuid;
fn test_ctx() -> ToolContext {
ToolContext {
session_id: "test-session".into(),
agent_id: "test-agent".into(),
agent_name: "test-agent".into(),
authority: InputAuthority::Creator,
workspace_root: std::env::current_dir().unwrap(),
tool_allowed_paths: vec![],
channel: None,
db: None,
sandbox: ToolSandboxSnapshot::default(),
}
}
#[test]
fn register_and_retrieve() {
let mut registry = ToolRegistry::new();
registry.register(Box::new(EchoTool));
let tool = registry.get("echo");
assert!(tool.is_some());
assert_eq!(tool.unwrap().name(), "echo");
assert_eq!(tool.unwrap().risk_level(), RiskLevel::Safe);
assert!(registry.get("nonexistent").is_none());
}
#[test]
fn list_tools() {
let mut registry = ToolRegistry::new();
assert!(registry.list().is_empty());
registry.register(Box::new(EchoTool));
let tools = registry.list();
assert_eq!(tools.len(), 1);
assert_eq!(tools[0].name(), "echo");
}
#[tokio::test]
async fn echo_tool_execution() {
let tool = EchoTool;
let ctx = test_ctx();
let params = serde_json::json!({ "message": "hello world" });
let result = tool.execute(params, &ctx).await.unwrap();
assert_eq!(result.output, "hello world");
assert!(result.metadata.is_none());
let bad_params = serde_json::json!({});
let err = tool.execute(bad_params, &ctx).await.unwrap_err();
assert!(err.message.contains("missing"));
}
#[tokio::test]
async fn bash_tool_executes_command_in_workspace() {
let dir = tempfile::tempdir().unwrap();
let root = std::fs::canonicalize(dir.path()).unwrap();
std::fs::write(root.join("probe.txt"), "ok").unwrap();
let tool = BashTool;
let ctx = ToolContext {
session_id: "test-session".into(),
agent_id: "test-agent".into(),
agent_name: "test-agent".into(),
authority: InputAuthority::Creator,
workspace_root: root.clone(),
tool_allowed_paths: vec![],
channel: None,
db: None,
sandbox: ToolSandboxSnapshot::default(),
};
let result = tool
.execute(serde_json::json!({"command": "ls", "cwd": "."}), &ctx)
.await
.unwrap();
assert!(result.output.contains("probe.txt"));
}
#[test]
fn wildcard_match_supports_star_and_question() {
assert!(wildcard_match("src/*.rs", "src/main.rs"));
assert!(wildcard_match("src/???.rs", "src/mod.rs"));
assert!(!wildcard_match("src/*.rs", "src/main.ts"));
}
#[test]
fn wildcard_match_single_star_does_not_cross_directories() {
assert!(wildcard_match("*.rs", "main.rs"));
assert!(!wildcard_match("*.rs", "src/main.rs"));
}
#[test]
fn wildcard_match_double_star_crosses_directories() {
assert!(wildcard_match("**/*.rs", "src/nested/deep/main.rs"));
assert!(wildcard_match("src/**/*.rs", "src/main.rs"));
assert!(wildcard_match("src/**/*.rs", "src/nested/main.rs"));
}
#[test]
fn walk_workspace_files_skips_hidden_and_node_modules_dirs() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join(".git/objects")).unwrap();
std::fs::write(root.join(".git/objects/hidden.txt"), "x").unwrap();
std::fs::create_dir_all(root.join("node_modules/pkg")).unwrap();
std::fs::write(root.join("node_modules/pkg/index.js"), "x").unwrap();
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(root.join("src/main.rs"), "fn main() {}").unwrap();
let mut files = Vec::new();
let mut count = 0usize;
walk_workspace_files(root, &mut files, &mut count).unwrap();
let rels: Vec<String> = files
.iter()
.map(|p| {
p.strip_prefix(root)
.unwrap()
.to_string_lossy()
.replace('\\', "/")
})
.collect();
assert!(rels.iter().any(|p| p == "src/main.rs"));
assert!(!rels.iter().any(|p| p.starts_with(".git/")));
assert!(!rels.iter().any(|p| p.starts_with("node_modules/")));
}
#[tokio::test]
async fn filesystem_tools_roundtrip() {
let ctx = test_ctx();
let unique = format!(".tmp_tools_test_{}", Uuid::new_v4());
let rel_file = format!("{unique}/note.txt");
let _ = std::fs::create_dir_all(&unique);
let write = WriteFileTool;
write
.execute(
serde_json::json!({"path": rel_file, "content": "hello tools"}),
&ctx,
)
.await
.unwrap();
let read = ReadFileTool;
let out = read
.execute(
serde_json::json!({"path": format!("{unique}/note.txt")}),
&ctx,
)
.await
.unwrap();
assert_eq!(out.output, "hello tools");
let list = ListDirectoryTool;
let listed = list
.execute(serde_json::json!({"path": unique.clone()}), &ctx)
.await
.unwrap();
assert!(listed.output.contains("note.txt"));
let _ = std::fs::remove_dir_all(unique);
}
#[test]
fn filesystem_tool_risk_levels_block_external_authority() {
assert_eq!(ReadFileTool.risk_level(), RiskLevel::Caution);
assert_eq!(ListDirectoryTool.risk_level(), RiskLevel::Caution);
assert_eq!(GlobFilesTool.risk_level(), RiskLevel::Caution);
assert_eq!(SearchFilesTool.risk_level(), RiskLevel::Caution);
}
#[tokio::test]
async fn search_files_skips_oversized_files() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("small.txt"), "needle").unwrap();
std::fs::write(dir.path().join("large.txt"), vec![b'a'; MAX_FILE_BYTES + 1]).unwrap();
let tool = SearchFilesTool;
let ctx = ToolContext {
session_id: "test-session".into(),
agent_id: "test-agent".into(),
agent_name: "test-agent".into(),
authority: InputAuthority::Creator,
workspace_root: dir.path().to_path_buf(),
tool_allowed_paths: vec![],
channel: None,
db: None,
sandbox: ToolSandboxSnapshot::default(),
};
let result = tool
.execute(serde_json::json!({"query": "needle", "path": "."}), &ctx)
.await
.unwrap();
assert!(result.output.contains("small.txt"));
assert!(!result.output.contains("large.txt"));
let metadata = result.metadata.unwrap();
assert_eq!(metadata["skipped_large_files"].as_u64(), Some(1));
}
#[test]
fn validate_rel_path_rejects_absolute() {
let path_str = if cfg!(windows) {
r"C:\Windows\System32\evil.txt"
} else {
"/etc/passwd"
};
let p = std::path::Path::new(path_str);
let err = validate_rel_path(p).unwrap_err();
assert!(err.message.contains("absolute"));
}
#[test]
fn validate_rel_path_rejects_parent_traversal() {
let p = std::path::Path::new("subdir/../../etc/passwd");
let err = validate_rel_path(p).unwrap_err();
assert!(err.message.contains("traversal"));
}
#[test]
fn validate_rel_path_accepts_normal() {
assert!(validate_rel_path(std::path::Path::new("src/main.rs")).is_ok());
assert!(validate_rel_path(std::path::Path::new("file.txt")).is_ok());
assert!(validate_rel_path(std::path::Path::new("a/b/c/d")).is_ok());
}
#[test]
fn resolve_workspace_path_nonexistent_disallowed() {
let dir = tempfile::tempdir().unwrap();
let root = std::fs::canonicalize(dir.path()).unwrap();
let err = resolve_workspace_path(&root, "does_not_exist.txt", false).unwrap_err();
assert!(err.message.contains("does not exist"));
}
#[test]
fn resolve_workspace_path_nonexistent_allowed() {
let dir = tempfile::tempdir().unwrap();
let root = std::fs::canonicalize(dir.path()).unwrap();
let result = resolve_workspace_path(&root, "new_file.txt", true).unwrap();
assert!(result.to_string_lossy().contains("new_file.txt"));
}
#[test]
fn resolve_workspace_path_existing_file() {
let dir = tempfile::tempdir().unwrap();
let root = std::fs::canonicalize(dir.path()).unwrap();
std::fs::write(root.join("hello.txt"), "hi").unwrap();
let result = resolve_workspace_path(&root, "hello.txt", false).unwrap();
assert!(result.starts_with(&root));
}
#[test]
fn resolve_workspace_path_accepts_workspace_absolute_path() {
let dir = tempfile::tempdir().unwrap();
let root = std::fs::canonicalize(dir.path()).unwrap();
let target = root.join("abs.txt");
std::fs::write(&target, "ok").unwrap();
let result = resolve_workspace_path(&root, &target.display().to_string(), false).unwrap();
assert_eq!(result, target);
}
#[test]
fn resolve_workspace_path_rejects_external_absolute_path() {
let dir = tempfile::tempdir().unwrap();
let root = std::fs::canonicalize(dir.path()).unwrap();
let external = if cfg!(windows) {
r"C:\tmp\not-in-workspace.txt"
} else {
"/tmp/not-in-workspace.txt"
};
let err = resolve_workspace_path(&root, external, false).unwrap_err();
assert!(err.message.contains("outside workspace root"));
}
#[test]
fn resolve_workspace_path_accepts_tilde_when_inside_workspace() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path().join("home");
let ws = home.join("code/roboticus");
std::fs::create_dir_all(&ws).unwrap();
std::fs::write(ws.join("Cargo.toml"), "[package]\nname='x'\n").unwrap();
let root = std::fs::canonicalize(&ws).unwrap();
unsafe {
std::env::set_var("HOME", &home);
}
let result = resolve_workspace_path(&root, "~/code/roboticus", false).unwrap();
assert_eq!(result, root);
}
#[cfg(unix)]
#[test]
fn resolve_workspace_path_symlink_escape() {
let dir = tempfile::tempdir().unwrap();
let root = std::fs::canonicalize(dir.path()).unwrap();
let link_path = root.join("escape");
std::os::unix::fs::symlink("/tmp", &link_path).unwrap();
let err = resolve_workspace_path(&root, "escape", false).unwrap_err();
assert!(err.message.contains("escapes workspace root"));
}
#[tokio::test]
async fn edit_file_tool_single_replacement() {
let dir = tempfile::tempdir().unwrap();
let root = std::fs::canonicalize(dir.path()).unwrap();
std::fs::write(root.join("edit_me.txt"), "foo bar foo baz").unwrap();
let tool = EditFileTool;
let ctx = ToolContext {
session_id: "test".into(),
agent_id: "test".into(),
agent_name: "test".into(),
authority: InputAuthority::Creator,
workspace_root: root.clone(),
tool_allowed_paths: vec![],
channel: None,
db: None,
sandbox: ToolSandboxSnapshot::default(),
};
let result = tool
.execute(
serde_json::json!({
"path": "edit_me.txt",
"old_text": "foo",
"new_text": "qux"
}),
&ctx,
)
.await
.unwrap();
assert_eq!(result.output, "ok");
let content = std::fs::read_to_string(root.join("edit_me.txt")).unwrap();
assert_eq!(content, "qux bar foo baz");
}
#[tokio::test]
async fn edit_file_tool_replace_all() {
let dir = tempfile::tempdir().unwrap();
let root = std::fs::canonicalize(dir.path()).unwrap();
std::fs::write(root.join("edit_me.txt"), "foo bar foo baz").unwrap();
let tool = EditFileTool;
let ctx = ToolContext {
session_id: "test".into(),
agent_id: "test".into(),
agent_name: "test".into(),
authority: InputAuthority::Creator,
workspace_root: root.clone(),
tool_allowed_paths: vec![],
channel: None,
db: None,
sandbox: ToolSandboxSnapshot::default(),
};
let result = tool
.execute(
serde_json::json!({
"path": "edit_me.txt",
"old_text": "foo",
"new_text": "qux",
"replace_all": true
}),
&ctx,
)
.await
.unwrap();
assert_eq!(result.output, "ok");
let content = std::fs::read_to_string(root.join("edit_me.txt")).unwrap();
assert_eq!(content, "qux bar qux baz");
}
#[tokio::test]
async fn edit_file_tool_old_text_not_found() {
let dir = tempfile::tempdir().unwrap();
let root = std::fs::canonicalize(dir.path()).unwrap();
std::fs::write(root.join("edit_me.txt"), "hello world").unwrap();
let tool = EditFileTool;
let ctx = ToolContext {
session_id: "test".into(),
agent_id: "test".into(),
agent_name: "test".into(),
authority: InputAuthority::Creator,
workspace_root: root.clone(),
tool_allowed_paths: vec![],
channel: None,
db: None,
sandbox: ToolSandboxSnapshot::default(),
};
let err = tool
.execute(
serde_json::json!({
"path": "edit_me.txt",
"old_text": "nonexistent",
"new_text": "replacement"
}),
&ctx,
)
.await
.unwrap_err();
assert!(err.message.contains("old_text not found"));
}
#[tokio::test]
async fn edit_file_tool_missing_params() {
let tool = EditFileTool;
let ctx = test_ctx();
let err = tool
.execute(
serde_json::json!({ "old_text": "a", "new_text": "b" }),
&ctx,
)
.await
.unwrap_err();
assert!(err.message.contains("missing 'path'"));
let err = tool
.execute(
serde_json::json!({ "path": "file.txt", "new_text": "b" }),
&ctx,
)
.await
.unwrap_err();
assert!(err.message.contains("missing 'old_text'"));
let err = tool
.execute(
serde_json::json!({ "path": "file.txt", "old_text": "a" }),
&ctx,
)
.await
.unwrap_err();
assert!(err.message.contains("missing 'new_text'"));
}
#[tokio::test]
async fn write_file_tool_append_mode() {
let dir = tempfile::tempdir().unwrap();
let root = std::fs::canonicalize(dir.path()).unwrap();
std::fs::write(root.join("log.txt"), "line 1\n").unwrap();
let tool = WriteFileTool;
let ctx = ToolContext {
session_id: "test".into(),
agent_id: "test".into(),
agent_name: "test".into(),
authority: InputAuthority::Creator,
workspace_root: root.clone(),
tool_allowed_paths: vec![],
channel: None,
db: None,
sandbox: ToolSandboxSnapshot::default(),
};
let result = tool
.execute(
serde_json::json!({
"path": "log.txt",
"content": "line 2\n",
"append": true
}),
&ctx,
)
.await
.unwrap();
assert_eq!(result.output, "ok");
let meta = result.metadata.unwrap();
assert_eq!(meta["append"], true);
let content = std::fs::read_to_string(root.join("log.txt")).unwrap();
assert_eq!(content, "line 1\nline 2\n");
}
#[tokio::test]
async fn write_file_tool_creates_parent_dirs() {
let dir = tempfile::tempdir().unwrap();
let root = std::fs::canonicalize(dir.path()).unwrap();
let tool = WriteFileTool;
let ctx = ToolContext {
session_id: "test".into(),
agent_id: "test".into(),
agent_name: "test".into(),
authority: InputAuthority::Creator,
workspace_root: root.clone(),
tool_allowed_paths: vec![],
channel: None,
db: None,
sandbox: ToolSandboxSnapshot::default(),
};
tool.execute(
serde_json::json!({
"path": "deep/nested/dir/file.txt",
"content": "deep content"
}),
&ctx,
)
.await
.unwrap();
let content = std::fs::read_to_string(root.join("deep/nested/dir/file.txt")).unwrap();
assert_eq!(content, "deep content");
}
#[tokio::test]
async fn search_files_case_sensitive() {
let dir = tempfile::tempdir().unwrap();
let root = std::fs::canonicalize(dir.path()).unwrap();
std::fs::write(
root.join("test.txt"),
"Hello World\nhello world\nHELLO WORLD",
)
.unwrap();
let tool = SearchFilesTool;
let ctx = ToolContext {
session_id: "test".into(),
agent_id: "test".into(),
agent_name: "test".into(),
authority: InputAuthority::Creator,
workspace_root: root.clone(),
tool_allowed_paths: vec![],
channel: None,
db: None,
sandbox: ToolSandboxSnapshot::default(),
};
let result = tool
.execute(
serde_json::json!({
"query": "Hello World",
"path": ".",
"case_sensitive": true
}),
&ctx,
)
.await
.unwrap();
let hits: Vec<Value> = serde_json::from_str(&result.output).unwrap();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0]["line"], 1);
let result = tool
.execute(
serde_json::json!({
"query": "Hello World",
"path": ".",
"case_sensitive": false
}),
&ctx,
)
.await
.unwrap();
let hits: Vec<Value> = serde_json::from_str(&result.output).unwrap();
assert_eq!(hits.len(), 3);
}
#[tokio::test]
async fn search_files_respects_limit() {
let dir = tempfile::tempdir().unwrap();
let root = std::fs::canonicalize(dir.path()).unwrap();
let content = (0..50)
.map(|i| format!("needle line {i}"))
.collect::<Vec<_>>()
.join("\n");
std::fs::write(root.join("many.txt"), content).unwrap();
let tool = SearchFilesTool;
let ctx = ToolContext {
session_id: "test".into(),
agent_id: "test".into(),
agent_name: "test".into(),
authority: InputAuthority::Creator,
workspace_root: root.clone(),
tool_allowed_paths: vec![],
channel: None,
db: None,
sandbox: ToolSandboxSnapshot::default(),
};
let result = tool
.execute(
serde_json::json!({
"query": "needle",
"path": ".",
"limit": 5
}),
&ctx,
)
.await
.unwrap();
let hits: Vec<Value> = serde_json::from_str(&result.output).unwrap();
assert_eq!(hits.len(), 5);
}
#[tokio::test]
async fn glob_files_tool_basic() {
let dir = tempfile::tempdir().unwrap();
let root = std::fs::canonicalize(dir.path()).unwrap();
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(root.join("src/main.rs"), "fn main(){}").unwrap();
std::fs::write(root.join("src/lib.rs"), "// lib").unwrap();
std::fs::write(root.join("readme.md"), "# readme").unwrap();
let tool = GlobFilesTool;
let ctx = ToolContext {
session_id: "test".into(),
agent_id: "test".into(),
agent_name: "test".into(),
authority: InputAuthority::Creator,
workspace_root: root.clone(),
tool_allowed_paths: vec![],
channel: None,
db: None,
sandbox: ToolSandboxSnapshot::default(),
};
let result = tool
.execute(
serde_json::json!({ "pattern": "src/*.rs", "path": "." }),
&ctx,
)
.await
.unwrap();
let matches: Vec<String> = serde_json::from_str(&result.output).unwrap();
assert_eq!(matches.len(), 2);
assert!(matches.iter().any(|m| m.contains("main.rs")));
assert!(matches.iter().any(|m| m.contains("lib.rs")));
}
#[tokio::test]
async fn glob_files_tool_respects_limit() {
let dir = tempfile::tempdir().unwrap();
let root = std::fs::canonicalize(dir.path()).unwrap();
for i in 0..10 {
std::fs::write(root.join(format!("file_{i}.txt")), "content").unwrap();
}
let tool = GlobFilesTool;
let ctx = ToolContext {
session_id: "test".into(),
agent_id: "test".into(),
agent_name: "test".into(),
authority: InputAuthority::Creator,
workspace_root: root.clone(),
tool_allowed_paths: vec![],
channel: None,
db: None,
sandbox: ToolSandboxSnapshot::default(),
};
let result = tool
.execute(
serde_json::json!({ "pattern": "*.txt", "path": ".", "limit": 3 }),
&ctx,
)
.await
.unwrap();
let matches: Vec<String> = serde_json::from_str(&result.output).unwrap();
assert_eq!(matches.len(), 3);
}
#[tokio::test]
async fn read_file_tool_missing_path_param() {
let tool = ReadFileTool;
let ctx = test_ctx();
let err = tool.execute(serde_json::json!({}), &ctx).await.unwrap_err();
assert!(err.message.contains("missing 'path'"));
}
#[tokio::test]
async fn read_file_tool_oversized_file() {
let dir = tempfile::tempdir().unwrap();
let root = std::fs::canonicalize(dir.path()).unwrap();
std::fs::write(root.join("big.txt"), vec![b'x'; MAX_FILE_BYTES + 1]).unwrap();
let tool = ReadFileTool;
let ctx = ToolContext {
session_id: "test".into(),
agent_id: "test".into(),
agent_name: "test".into(),
authority: InputAuthority::Creator,
workspace_root: root.clone(),
tool_allowed_paths: vec![],
channel: None,
db: None,
sandbox: ToolSandboxSnapshot::default(),
};
let err = tool
.execute(serde_json::json!({ "path": "big.txt" }), &ctx)
.await
.unwrap_err();
assert!(err.message.contains("file too large"));
}
#[tokio::test]
async fn write_file_tool_missing_params() {
let tool = WriteFileTool;
let ctx = test_ctx();
let err = tool
.execute(serde_json::json!({ "content": "hi" }), &ctx)
.await
.unwrap_err();
assert!(err.message.contains("missing 'path'"));
let err = tool
.execute(serde_json::json!({ "path": "file.txt" }), &ctx)
.await
.unwrap_err();
assert!(err.message.contains("missing 'content'"));
}
#[tokio::test]
async fn list_directory_tool_default_path() {
let dir = tempfile::tempdir().unwrap();
let root = std::fs::canonicalize(dir.path()).unwrap();
std::fs::write(root.join("a.txt"), "a").unwrap();
std::fs::create_dir(root.join("subdir")).unwrap();
let tool = ListDirectoryTool;
let ctx = ToolContext {
session_id: "test".into(),
agent_id: "test".into(),
agent_name: "test".into(),
authority: InputAuthority::Creator,
workspace_root: root.clone(),
tool_allowed_paths: vec![],
channel: None,
db: None,
sandbox: ToolSandboxSnapshot::default(),
};
let result = tool.execute(serde_json::json!({}), &ctx).await.unwrap();
assert!(result.output.contains("a.txt"));
assert!(result.output.contains("subdir"));
let meta = result.metadata.unwrap();
assert_eq!(meta["count"], 2);
}
#[tokio::test]
async fn list_directory_tool_allows_configured_absolute_path() {
let workspace = tempfile::tempdir().unwrap();
let workspace_root = std::fs::canonicalize(workspace.path()).unwrap();
let external = tempfile::tempdir().unwrap();
let external_root = std::fs::canonicalize(external.path()).unwrap();
std::fs::write(external_root.join("allowed.txt"), "ok").unwrap();
let tool = ListDirectoryTool;
let ctx = ToolContext {
session_id: "test".into(),
agent_id: "test".into(),
agent_name: "test".into(),
authority: InputAuthority::Creator,
workspace_root: workspace_root.clone(),
tool_allowed_paths: vec![external_root.clone()],
channel: None,
db: None,
sandbox: ToolSandboxSnapshot::default(),
};
let result = tool
.execute(
serde_json::json!({ "path": external_root.display().to_string() }),
&ctx,
)
.await
.unwrap();
assert!(result.output.contains("allowed.txt"));
}
#[tokio::test]
async fn list_directory_tool_denies_unconfigured_absolute_path() {
let workspace = tempfile::tempdir().unwrap();
let workspace_root = std::fs::canonicalize(workspace.path()).unwrap();
let external = tempfile::tempdir().unwrap();
let external_root = std::fs::canonicalize(external.path()).unwrap();
std::fs::write(external_root.join("blocked.txt"), "nope").unwrap();
let tool = ListDirectoryTool;
let ctx = ToolContext {
session_id: "test".into(),
agent_id: "test".into(),
agent_name: "test".into(),
authority: InputAuthority::Creator,
workspace_root: workspace_root.clone(),
tool_allowed_paths: vec![],
channel: None,
db: None,
sandbox: ToolSandboxSnapshot::default(),
};
let err = tool
.execute(
serde_json::json!({ "path": external_root.display().to_string() }),
&ctx,
)
.await
.unwrap_err();
assert!(err.message.contains("outside workspace root"));
}
#[test]
fn tool_error_display() {
let err = ToolError {
message: "something went wrong".into(),
};
let displayed = format!("{err}");
assert_eq!(displayed, "ToolError: something went wrong");
}
#[test]
fn tool_registry_default() {
let reg = ToolRegistry::default();
assert!(reg.list().is_empty());
}
#[test]
fn wildcard_match_exact_filename() {
assert!(wildcard_match("main.rs", "main.rs"));
assert!(!wildcard_match("main.rs", "lib.rs"));
}
#[test]
fn wildcard_match_consecutive_double_stars() {
assert!(wildcard_match("**/**/*.rs", "src/nested/main.rs"));
assert!(wildcard_match("src/**/**/*.rs", "src/a/b/c/main.rs"));
}
#[test]
fn wildcard_match_empty_candidate() {
assert!(!wildcard_match("*.rs", ""));
}
#[test]
fn wildcard_match_double_star_alone() {
assert!(wildcard_match("**", "src/main.rs"));
assert!(wildcard_match("**", "a/b/c/d/e.txt"));
}
#[test]
fn wildcard_match_question_mark_does_not_cross_directories() {
assert!(wildcard_match("src/?.rs", "src/a.rs"));
assert!(!wildcard_match("src/?.rs", "src/ab.rs")); }
#[test]
fn wildcard_match_segment_with_star_in_middle() {
assert!(wildcard_match_segment("foo*bar", "foobazbar"));
assert!(wildcard_match_segment("foo*bar", "foobar"));
assert!(!wildcard_match_segment("foo*bar", "foobaz"));
}
#[test]
fn wildcard_match_segment_exact() {
assert!(wildcard_match_segment("hello", "hello"));
assert!(!wildcard_match_segment("hello", "helo"));
}
#[test]
fn wildcard_match_segment_question_mark() {
assert!(wildcard_match_segment("h?llo", "hello"));
assert!(wildcard_match_segment("h?llo", "hxllo"));
assert!(!wildcard_match_segment("h?llo", "hlo"));
}
#[cfg(unix)]
#[test]
fn walk_workspace_files_skips_symlinks() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(root.join("real.txt"), "real content").unwrap();
std::os::unix::fs::symlink("/tmp", root.join("symlink_dir")).unwrap();
std::os::unix::fs::symlink(root.join("real.txt"), root.join("symlink_file")).unwrap();
let mut files = Vec::new();
let mut count = 0usize;
walk_workspace_files(root, &mut files, &mut count).unwrap();
let names: Vec<String> = files
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().to_string())
.collect();
assert!(names.contains(&"real.txt".to_string()));
assert!(!names.contains(&"symlink_dir".to_string()));
assert!(!names.contains(&"symlink_file".to_string()));
}
#[test]
fn edit_file_tool_metadata() {
assert_eq!(EditFileTool.name(), "edit_file");
assert_eq!(EditFileTool.risk_level(), RiskLevel::Caution);
let schema = EditFileTool.parameters_schema();
let required = schema["required"].as_array().unwrap();
assert!(required.iter().any(|v| v == "path"));
assert!(required.iter().any(|v| v == "old_text"));
assert!(required.iter().any(|v| v == "new_text"));
}
#[test]
fn glob_files_tool_metadata() {
assert_eq!(GlobFilesTool.name(), "glob_files");
assert_eq!(GlobFilesTool.risk_level(), RiskLevel::Caution);
let schema = GlobFilesTool.parameters_schema();
let required = schema["required"].as_array().unwrap();
assert!(required.iter().any(|v| v == "pattern"));
}
#[test]
fn search_files_tool_metadata() {
assert_eq!(SearchFilesTool.name(), "search_files");
assert_eq!(SearchFilesTool.risk_level(), RiskLevel::Caution);
let schema = SearchFilesTool.parameters_schema();
let required = schema["required"].as_array().unwrap();
assert!(required.iter().any(|v| v == "query"));
}
#[test]
fn script_runner_tool_metadata() {
let cfg = SkillsConfig::default();
let tool = ScriptRunnerTool::new(
cfg,
roboticus_core::config::FilesystemSecurityConfig::for_tests(),
);
assert_eq!(tool.name(), "run_script");
assert_eq!(tool.risk_level(), RiskLevel::Caution);
}
#[test]
fn script_runner_error_classification() {
assert_eq!(
classify_script_runner_error("script timed out after 30s"),
"SCRIPT_TIMEOUT"
);
assert_eq!(
classify_script_runner_error("absolute script paths are not allowed"),
"SCRIPT_PATH_INVALID"
);
assert_eq!(
classify_script_runner_error("interpreter 'ruby' not in whitelist"),
"SCRIPT_INTERPRETER_DENIED"
);
assert_eq!(
classify_script_runner_error("failed to spawn bash"),
"SCRIPT_SPAWN_FAILED"
);
assert_eq!(
classify_script_runner_error("some generic failure"),
"SCRIPT_RUNTIME_ERROR"
);
}
#[cfg(unix)]
#[tokio::test]
async fn run_script_tool_success_metadata_is_typed() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let script_path = dir.path().join("ok.sh");
std::fs::write(&script_path, "#!/bin/bash\necho ok").unwrap();
std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755)).unwrap();
let cfg = SkillsConfig {
skills_dir: dir.path().to_path_buf(),
allowed_interpreters: vec!["bash".to_string()],
sandbox_env: false,
..Default::default()
};
let tool = ScriptRunnerTool::new(
cfg,
roboticus_core::config::FilesystemSecurityConfig::for_tests(),
);
let ctx = test_ctx();
let out = tool
.execute(serde_json::json!({"path": "ok.sh"}), &ctx)
.await
.unwrap();
let meta = out.metadata.expect("metadata expected");
assert_eq!(meta["adapter"], "script_runner");
assert_eq!(meta["schema_version"], 1);
assert_eq!(meta["status"], "ok");
assert!(meta["error_class"].is_null());
assert_eq!(meta["exit_code"], 0);
assert!(meta["duration_ms"].is_u64());
}
#[cfg(unix)]
#[tokio::test]
async fn run_script_tool_nonzero_exit_is_error() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let script_path = dir.path().join("fail.sh");
std::fs::write(&script_path, "#!/bin/bash\necho boom >&2\nexit 7").unwrap();
std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755)).unwrap();
let cfg = SkillsConfig {
skills_dir: dir.path().to_path_buf(),
allowed_interpreters: vec!["bash".to_string()],
..Default::default()
};
let tool = ScriptRunnerTool::new(
cfg,
roboticus_core::config::FilesystemSecurityConfig::for_tests(),
);
let ctx = test_ctx();
let err = tool
.execute(serde_json::json!({"path": "fail.sh"}), &ctx)
.await
.unwrap_err();
assert!(err.message.starts_with("SCRIPT_EXIT_NONZERO:"));
assert!(err.message.contains("script exited with code 7"));
}
#[tokio::test]
async fn get_runtime_context_returns_all_fields() {
let tool = GetRuntimeContextTool;
let mut ctx = test_ctx();
ctx.channel = Some("telegram".into());
let result = tool.execute(serde_json::json!({}), &ctx).await.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result.output).unwrap();
assert_eq!(parsed["agent_id"], "test-agent");
assert_eq!(parsed["session_id"], "test-session");
assert_eq!(parsed["channel"], "telegram");
assert!(parsed["workspace_root"].is_string());
assert_eq!(parsed["storage"]["hippocampus_available"], false);
assert!(parsed["sandbox"]["filesystem"]["workspace_only"].is_boolean());
assert!(parsed["sandbox"]["skills"]["network_allowed"].is_boolean());
assert!(parsed["how_to_change_boundaries"]["toml_keys"].is_array());
assert!(
parsed["how_to_change_boundaries"]["documentation"]
.as_str()
.unwrap()
.contains("CONFIGURATION")
);
assert!(result.metadata.is_some());
}
#[tokio::test]
async fn get_runtime_context_no_channel() {
let tool = GetRuntimeContextTool;
let ctx = test_ctx();
let result = tool.execute(serde_json::json!({}), &ctx).await.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result.output).unwrap();
assert!(parsed["channel"].is_null());
}
#[tokio::test]
async fn get_runtime_context_includes_hippocampus_storage_awareness() {
let tool = GetRuntimeContextTool;
let db = roboticus_db::Database::new(":memory:").unwrap();
roboticus_db::hippocampus::create_agent_table(
&db,
"testagent",
"notes",
"Agent scratchpad",
&[roboticus_db::hippocampus::ColumnDef {
name: "body".into(),
col_type: "TEXT".into(),
nullable: true,
description: Some("Note body".into()),
}],
)
.unwrap();
roboticus_db::hippocampus::register_table(
&db,
"knowledge:playbook.md",
"Ingested playbook (markdown, 3 chunks)",
&[],
"system",
false,
"read",
3,
)
.unwrap();
let mut ctx = test_ctx();
ctx.agent_id = "testagent".into();
ctx.agent_name = "testagent".into();
ctx.db = Some(db);
let result = tool.execute(serde_json::json!({}), &ctx).await.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result.output).unwrap();
assert_eq!(parsed["storage"]["hippocampus_available"], true);
assert_eq!(parsed["storage"]["owned_table_count"], 1);
assert_eq!(parsed["storage"]["knowledge_source_count"], 1);
assert!(
parsed["storage"]["compact_summary"]
.as_str()
.unwrap()
.contains("Knowledge sources:")
);
assert_eq!(
parsed["storage"]["owned_tables"][0]["table_name"],
"testagent_notes"
);
assert_eq!(
parsed["storage"]["knowledge_sources"][0]["table_name"],
"knowledge:playbook.md"
);
}
#[tokio::test]
async fn get_memory_stats_returns_all_tiers() {
let tool = GetMemoryStatsTool;
let ctx = test_ctx();
let result = tool.execute(serde_json::json!({}), &ctx).await.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result.output).unwrap();
let tiers = &parsed["tiers"];
assert_eq!(tiers["working"]["budget_pct"], 30);
assert_eq!(tiers["episodic"]["budget_pct"], 25);
assert_eq!(tiers["semantic"]["budget_pct"], 20);
assert_eq!(tiers["procedural"]["budget_pct"], 15);
assert_eq!(tiers["relationship"]["budget_pct"], 10);
assert_eq!(
parsed["lifecycle_policy"]["inactive_states_suppressed_by_default"],
true
);
assert!(
parsed["retrieval_method"]
.as_str()
.unwrap()
.contains("FTS5")
);
assert_eq!(parsed["live"]["available"], false);
}
#[tokio::test]
async fn get_memory_stats_reports_live_memory_health() {
let tool = GetMemoryStatsTool;
let db = roboticus_db::Database::new(":memory:").unwrap();
roboticus_db::schema::initialize_db(&db).unwrap();
let session_id =
roboticus_db::sessions::find_or_create(&db, "memory-health-agent", None).unwrap();
roboticus_db::memory::store_working(&db, &session_id, "goal", "Keep track of context", 9)
.unwrap();
roboticus_db::memory::store_episodic_with_meta(
&db,
"digest",
"recent digest",
8,
Some("memory-health-agent"),
"active",
None,
)
.unwrap();
roboticus_db::memory::store_episodic_with_meta(
&db,
"digest",
"old digest",
4,
Some("memory-health-agent"),
"stale",
Some("superseded"),
)
.unwrap();
roboticus_db::memory::store_semantic(
&db,
"learned",
"session:test:latest",
"Current semantic summary",
0.8,
)
.unwrap();
let stale_id = roboticus_db::memory::store_semantic(
&db,
"learned",
"session:test:old",
"Old semantic summary",
0.6,
)
.unwrap();
db.conn()
.execute(
"UPDATE semantic_memory SET memory_state = 'stale' WHERE id = ?1",
[&stale_id],
)
.unwrap();
roboticus_db::memory::store_procedural(&db, "echo", "echo back input").unwrap();
roboticus_db::memory::record_procedural_success(&db, "echo").unwrap();
roboticus_db::memory::store_relationship_interaction(
&db,
"peer:telegram:alice",
"Alice",
0.7,
Some("Greeted Alice"),
)
.unwrap();
let mut ctx = test_ctx();
ctx.session_id = session_id.clone();
ctx.db = Some(db);
let result = tool.execute(serde_json::json!({}), &ctx).await.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result.output).unwrap();
assert_eq!(parsed["live"]["available"], true);
let snapshot = &parsed["live"]["snapshot"];
assert_eq!(snapshot["session_id"], session_id);
assert_eq!(snapshot["working"]["session_entries"], 1);
assert_eq!(snapshot["episodic"]["active"], 1);
assert_eq!(snapshot["episodic"]["stale"], 1);
assert_eq!(snapshot["semantic"]["active"], 1);
assert_eq!(snapshot["semantic"]["stale"], 1);
assert_eq!(snapshot["procedural"]["utilized"], 1);
assert_eq!(snapshot["relationship"]["total_interactions"], 1);
}
#[tokio::test]
async fn get_channel_health_with_channel() {
let tool = GetChannelHealthTool;
let mut ctx = test_ctx();
ctx.channel = Some("discord".into());
let result = tool.execute(serde_json::json!({}), &ctx).await.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result.output).unwrap();
assert_eq!(parsed["channel"], "discord");
assert_eq!(parsed["status"], "operational");
}
#[tokio::test]
async fn get_channel_health_unknown_channel() {
let tool = GetChannelHealthTool;
let ctx = test_ctx();
let result = tool.execute(serde_json::json!({}), &ctx).await.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result.output).unwrap();
assert_eq!(parsed["channel"], "unknown");
}
#[test]
fn introspection_tools_metadata() {
let rt = GetRuntimeContextTool;
assert_eq!(rt.name(), "get_runtime_context");
assert_eq!(rt.risk_level(), RiskLevel::Safe);
let ms = GetMemoryStatsTool;
assert_eq!(ms.name(), "get_memory_stats");
assert_eq!(ms.risk_level(), RiskLevel::Safe);
let ch = GetChannelHealthTool;
assert_eq!(ch.name(), "get_channel_health");
assert_eq!(ch.risk_level(), RiskLevel::Safe);
let sa = GetSubagentStatusTool;
assert_eq!(sa.name(), "get_subagent_status");
assert_eq!(sa.risk_level(), RiskLevel::Safe);
}
#[tokio::test]
async fn get_subagent_status_without_db_returns_empty() {
let tool = GetSubagentStatusTool;
let ctx = test_ctx(); let result = tool.execute(serde_json::json!({}), &ctx).await.unwrap();
let v: Value = serde_json::from_str(&result.output).unwrap();
assert_eq!(v["subagents"], serde_json::json!([]));
assert_eq!(v["tasks"], serde_json::json!([]));
assert!(
v["error"]
.as_str()
.unwrap()
.contains("database not available")
);
}
#[tokio::test]
async fn get_subagent_status_with_db_returns_agents_and_tasks() {
let db = roboticus_db::Database::new(":memory:").unwrap();
roboticus_db::agents::upsert_sub_agent(
&db,
&roboticus_db::agents::SubAgentRow {
id: "sa-1".into(),
name: "code-reviewer".into(),
display_name: Some("Code Reviewer".into()),
model: "gpt-4o".into(),
fallback_models_json: Some("[]".into()),
role: "specialist".into(),
description: Some("Reviews code".into()),
skills_json: None,
enabled: true,
session_count: 3,
last_used_at: None,
},
)
.unwrap();
{
let conn = db.conn();
conn.execute(
"INSERT INTO tasks (id, title, status, priority, source) VALUES ('t1', 'Fix bug', 'pending', 2, 'pg:agentic_bot:tasks')",
[],
).unwrap();
conn.execute(
"INSERT INTO tasks (id, title, status, priority, source) VALUES ('t2', 'Write docs', 'in_progress', 1, '{\"origin\":\"pg:mentat:tasks\",\"metadata\":{\"type\":\"revenue\"}}')",
[],
).unwrap();
conn.execute(
"INSERT INTO tasks (id, title, status, priority) VALUES ('t3', 'Done task', 'completed', 0)",
[],
).unwrap();
}
let ctx = ToolContext {
session_id: "test-session".into(),
agent_id: "test-agent".into(),
agent_name: "test-agent".into(),
authority: InputAuthority::Creator,
workspace_root: std::env::current_dir().unwrap(),
tool_allowed_paths: vec![],
channel: None,
db: Some(db),
sandbox: ToolSandboxSnapshot::default(),
};
let tool = GetSubagentStatusTool;
let result = tool.execute(serde_json::json!({}), &ctx).await.unwrap();
let v: Value = serde_json::from_str(&result.output).unwrap();
assert_eq!(v["subagent_count"], 1);
assert_eq!(v["subagents"][0]["name"], "code-reviewer");
assert_eq!(v["subagents"][0]["enabled"], true);
assert_eq!(v["open_task_count"], 2);
assert_eq!(v["tasks"][0]["title"], "Fix bug");
assert_eq!(v["tasks"][1]["title"], "Write docs");
assert_eq!(v["tasks"][0]["source"]["origin"], "pg:agentic_bot:tasks");
assert_eq!(v["tasks"][1]["source"]["origin"], "pg:mentat:tasks");
assert_eq!(v["tasks"][1]["source"]["metadata"]["type"], "revenue");
}
fn test_ctx_with_db() -> ToolContext {
let db = roboticus_db::Database::new(":memory:").expect("in-memory db");
ToolContext {
session_id: "test-session".into(),
agent_id: "testagent".into(), agent_name: "testagent".into(),
authority: InputAuthority::Creator,
workspace_root: std::env::current_dir().unwrap(),
tool_allowed_paths: vec![],
channel: None,
db: Some(db),
sandbox: ToolSandboxSnapshot::default(),
}
}
#[tokio::test]
async fn create_table_basic() {
let ctx = test_ctx_with_db();
let tool = CreateTableTool;
let params = serde_json::json!({
"name": "notes",
"description": "Agent scratchpad",
"columns": [
{"name": "title", "type": "TEXT"},
{"name": "body", "type": "TEXT"},
]
});
let result = tool.execute(params, &ctx).await.unwrap();
let v: Value = serde_json::from_str(&result.output).unwrap();
assert_eq!(v["table_name"], "testagent_notes");
assert_eq!(v["columns_created"], 2);
}
#[tokio::test]
async fn create_table_rejects_reserved_column() {
let ctx = test_ctx_with_db();
let tool = CreateTableTool;
let params = serde_json::json!({
"name": "bad",
"description": "test",
"columns": [{"name": "rowid", "type": "INTEGER"}]
});
let err = tool.execute(params, &ctx).await.unwrap_err();
assert!(err.message.contains("reserved"), "got: {}", err.message);
}
#[tokio::test]
async fn create_table_rejects_invalid_type() {
let ctx = test_ctx_with_db();
let tool = CreateTableTool;
let params = serde_json::json!({
"name": "bad",
"description": "test",
"columns": [{"name": "val", "type": "JSON"}]
});
let err = tool.execute(params, &ctx).await.unwrap_err();
assert!(err.message.contains("type"), "got: {}", err.message);
}
#[tokio::test]
async fn create_table_enforces_max_columns() {
let ctx = test_ctx_with_db();
let tool = CreateTableTool;
let cols: Vec<Value> = (0..MAX_COLUMNS_PER_TABLE + 1)
.map(|i| serde_json::json!({"name": format!("c{i}"), "type": "TEXT"}))
.collect();
let params = serde_json::json!({
"name": "wide",
"description": "too many columns",
"columns": cols
});
let err = tool.execute(params, &ctx).await.unwrap_err();
assert!(err.message.contains("columns"), "got: {}", err.message);
}
#[tokio::test]
async fn alter_table_add_and_drop_column() {
let ctx = test_ctx_with_db();
CreateTableTool
.execute(
serde_json::json!({
"name": "tasks",
"description": "task list",
"columns": [{"name": "title", "type": "TEXT"}]
}),
&ctx,
)
.await
.unwrap();
let alter = AlterTableTool;
let result = alter
.execute(
serde_json::json!({
"table_name": "testagent_tasks",
"operation": "add_column",
"column": {"name": "priority", "type": "INTEGER"}
}),
&ctx,
)
.await
.unwrap();
let v: Value = serde_json::from_str(&result.output).unwrap();
assert_eq!(v["operation"], "add_column");
assert_eq!(v["column_name"], "priority");
let result = alter
.execute(
serde_json::json!({
"table_name": "testagent_tasks",
"operation": "drop_column",
"column": {"name": "priority"}
}),
&ctx,
)
.await
.unwrap();
let v: Value = serde_json::from_str(&result.output).unwrap();
assert_eq!(v["operation"], "drop_column");
assert_eq!(v["column_name"], "priority");
}
#[tokio::test]
async fn alter_table_rejects_non_owned_table() {
let ctx = test_ctx_with_db();
let alter = AlterTableTool;
let err = alter
.execute(
serde_json::json!({
"table_name": "sessions",
"operation": "add_column",
"column": {"name": "hack", "type": "TEXT"}
}),
&ctx,
)
.await
.unwrap_err();
assert!(
err.message.contains("not owned") || err.message.contains("not found"),
"got: {}",
err.message
);
}
#[tokio::test]
async fn drop_table_basic() {
let ctx = test_ctx_with_db();
CreateTableTool
.execute(
serde_json::json!({
"name": "temp",
"description": "throwaway",
"columns": [{"name": "data", "type": "BLOB"}]
}),
&ctx,
)
.await
.unwrap();
let drop = DropTableTool;
let result = drop
.execute(serde_json::json!({"table_name": "testagent_temp"}), &ctx)
.await
.unwrap();
let v: Value = serde_json::from_str(&result.output).unwrap();
assert_eq!(v["status"], "dropped");
}
#[tokio::test]
async fn drop_table_rejects_system_table() {
let ctx = test_ctx_with_db();
let drop = DropTableTool;
let err = drop
.execute(serde_json::json!({"table_name": "sessions"}), &ctx)
.await
.unwrap_err();
assert!(
err.message.contains("not owned") || err.message.contains("drop"),
"got: {}",
err.message
);
}
#[tokio::test]
async fn data_tools_require_db() {
let ctx = test_ctx(); let err = CreateTableTool
.execute(
serde_json::json!({
"name": "x",
"description": "y",
"columns": [{"name": "a", "type": "TEXT"}]
}),
&ctx,
)
.await
.unwrap_err();
assert!(err.message.contains("database"), "got: {}", err.message);
}