use anyhow::Context;
pub mod ask;
pub mod browser;
pub mod edit;
pub mod image_gen;
pub mod path;
pub mod read;
pub mod search;
pub mod search_archived_tickets;
pub mod shell;
pub mod ticket;
pub mod video_gen;
pub mod web_search;
pub(crate) const MAX_FILE_SIZE_BYTES: u64 = 10 * 1024 * 1024;
pub(crate) const MAX_REFERENCE_IMAGE_BYTES: u64 = 1_500_000;
pub(crate) const PATH_ALIAS_KEYS: &[&str] = &["file", "filename"];
pub(crate) fn check_file_size(meta: &std::fs::Metadata) -> anyhow::Result<()> {
if meta.len() > MAX_FILE_SIZE_BYTES {
anyhow::bail!(
"File too large: {} bytes (limit: {} bytes)",
meta.len(),
MAX_FILE_SIZE_BYTES
);
}
Ok(())
}
pub use ask::AskTool;
pub use browser::BrowserTool;
pub use edit::EditTool;
pub use image_gen::ImageGenTool;
pub use read::ReadTool;
pub use search::SearchTool;
pub use search_archived_tickets::SearchArchivedTicketsTool;
pub use shell::{ShellMode, ShellTool};
pub use ticket::{
AddCommentTool, CreateTicketTool, GetTicketTool, ListTicketsTool, UpdateTicketTool,
};
pub use video_gen::VideoGenTool;
pub use web_search::WebSearchTool;
use crate::{Tool, Workspace};
use std::path::PathBuf;
pub(crate) use crate::util::json::{
get_bool, get_opt_bool, get_opt_i64, get_opt_str, get_opt_u64, get_str, get_str_array,
get_usize,
};
#[must_use]
pub(crate) fn find_path_arg(args: &serde_json::Value) -> Option<&str> {
std::iter::once("path")
.chain(PATH_ALIAS_KEYS.iter().copied())
.find_map(|k| args.get(k))
.and_then(|v| v.as_str())
}
pub(crate) fn require_path_arg(args: &serde_json::Value) -> anyhow::Result<String> {
find_path_arg(args).map(ToString::to_string).ok_or_else(|| {
anyhow::anyhow!(
"Missing required field: 'path'. \
Example: {{\"path\": \"src/main.rs\"}}"
)
})
}
#[must_use]
pub(crate) fn path_contains_wildcard(path: &str) -> bool {
path.contains(['*', '?', '[', ']'])
}
#[must_use]
pub(crate) fn tool_params_schema(
properties: &serde_json::Value,
required: &[&str],
) -> serde_json::Value {
let mut schema = serde_json::json!({
"type": "object",
"properties": properties,
});
if !required.is_empty() {
schema["required"] = serde_json::json!(required);
}
schema
}
use crate::util::scrub_credentials;
#[must_use]
pub fn sanitize_success_tool_output(
tool: &dyn Tool,
call_arguments: &serde_json::Value,
output: &str,
) -> String {
if tool.should_scrub_output(call_arguments) {
scrub_credentials(output)
} else {
output.to_string()
}
}
#[must_use]
pub fn format_tool_failure_feedback(
tool_name: &str,
tool_args: &serde_json::Value,
reason: &str,
) -> String {
let args_preview = scrub_credentials(&crate::util::truncate(&tool_args.to_string(), 1000));
format!(
"Tool call failed.\n\
tool: {tool_name}\n\
arguments: {args_preview}\n\
reason:\n{reason}"
)
}
#[derive(Debug, Clone)]
pub struct ToolExecutionOutcome {
pub output: String,
pub success: bool,
}
#[must_use]
pub fn normalize_tool_call(name: &str, args: serde_json::Value) -> (String, serde_json::Value) {
let (normalized_name, mut args) = normalize_tool_name(name, args);
normalize_tool_arguments(&normalized_name, &mut args);
(normalized_name, args)
}
fn normalize_tool_name_str(name: &str) -> &str {
match name {
"bash" | "run_terminal_cmd" => "shell",
"grep" | "rg" | "grep_search" | "glob" => "search",
"read_file" => "read",
"str_replace" => "edit",
_ => name,
}
}
fn normalize_tool_name(name: &str, mut args: serde_json::Value) -> (String, serde_json::Value) {
if name == "glob"
&& let Some(obj) = args.as_object_mut()
&& !obj.contains_key("mode")
{
obj.insert("mode".to_string(), serde_json::json!("files"));
}
let normalized = normalize_tool_name_str(name);
(normalized.to_string(), args)
}
fn normalize_tool_arguments(name: &str, args: &mut serde_json::Value) {
let Some(obj) = args.as_object_mut() else {
return;
};
match name {
"shell" => {
remap_arg_key(obj, "cmd", "command");
remap_arg_key(obj, "script", "command");
}
"get_ticket" | "update_ticket" | "add_comment" => {
remap_arg_key(obj, "id", "ticket_id");
remap_arg_key(obj, "ticket", "ticket_id");
}
"edit" => {
remap_arg_key(obj, "old_str", "old_string");
remap_arg_key(obj, "new_str", "new_string");
}
_ => {}
}
}
fn remap_arg_key(obj: &mut serde_json::Map<String, serde_json::Value>, from: &str, to: &str) {
if !obj.contains_key(to)
&& let Some(v) = obj.remove(from)
{
obj.insert(to.to_string(), v);
}
}
#[must_use]
pub fn find_tool<'a>(tools: &'a [Box<dyn Tool>], name: &str) -> Option<&'a dyn Tool> {
let normalized = normalize_tool_name_str(name);
tools
.iter()
.find(|t| t.name() == normalized)
.map(Box::as_ref)
}
#[must_use]
pub fn unknown_tool_message(call_name: &str) -> String {
format!("Unknown tool: {call_name}")
}
pub(crate) async fn save_generated_file(
ws: &Workspace,
bytes: &[u8],
prefix: &str,
ext: &str,
) -> anyhow::Result<PathBuf> {
let generated_dir = ws.as_path().join("generated");
tokio::fs::create_dir_all(&generated_dir)
.await
.with_context(|| {
format!(
"Failed to create generated directory at {}",
generated_dir.display()
)
})?;
let timestamp = crate::util::unix_millis();
let output_path = generated_dir.join(format!("{prefix}_{timestamp}.{ext}"));
tokio::fs::write(&output_path, bytes)
.await
.with_context(|| {
format!(
"Failed to write generated file to {}",
output_path.display()
)
})?;
Ok(output_path)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Tool;
use crate::ToolSpec;
use tempfile::TempDir;
#[test]
fn tool_spec_serde_roundtrip() {
let spec = ToolSpec {
name: "test".into(),
description: "A test tool".into(),
parameters: serde_json::json!({"type": "object"}),
};
let parsed: ToolSpec =
serde_json::from_str(&serde_json::to_string(&spec).unwrap()).unwrap();
assert_eq!(parsed.name, "test");
}
#[test]
fn find_tool_aliases() {
let tools: Vec<Box<dyn Tool>> = vec![
Box::new(SearchTool),
Box::new(ShellTool::new(ShellMode::Full)),
Box::new(ReadTool),
Box::new(EditTool),
];
let cases: &[(&str, Option<&str>)] = &[
("search", Some("search")),
("shell", Some("shell")),
("read", Some("read")),
("edit", Some("edit")),
("bash", Some("shell")),
("run_terminal_cmd", Some("shell")),
("grep", Some("search")),
("rg", Some("search")),
("grep_search", Some("search")),
("glob", Some("search")),
("read_file", Some("read")),
("str_replace", Some("edit")),
("unknown", None),
];
for &(input, expected) in cases {
let found = find_tool(&tools, input);
assert_eq!(found.map(Tool::name), expected, "find_tool({input:?})");
}
}
#[test]
fn path_contains_wildcard_detects_globs() {
assert!(path_contains_wildcard("src/*.rs"));
assert!(path_contains_wildcard("lib?.rs"));
assert!(!path_contains_wildcard("src/main.rs"));
}
#[test]
fn normalize_tool_call_repairs_names_and_args() {
let (name, args) = normalize_tool_call("bash", serde_json::json!({"cmd": "echo hi"}));
assert_eq!(name, "shell");
assert_eq!(args["command"], "echo hi");
let (name, args) = normalize_tool_call("glob", serde_json::json!({"query": "main.rs"}));
assert_eq!(name, "search");
assert_eq!(args["mode"], "files");
let (name, args) = normalize_tool_call("get_ticket", serde_json::json!({"id": "mahbot-1"}));
assert_eq!(name, "get_ticket");
assert_eq!(args["ticket_id"], "mahbot-1");
}
#[test]
fn all_media_tools_implement_media_marker() {
macro_rules! assert_media_marker {
($tool:expr) => {
assert!(
$tool.media_marker().is_some(),
"{} should return Some from media_marker()",
$tool.name(),
);
};
}
assert_media_marker!(ImageGenTool);
assert_media_marker!(VideoGenTool);
}
#[test]
fn require_path_arg_resolves_aliases() {
assert_eq!(
require_path_arg(&serde_json::json!({"path": "src/main.rs"})).unwrap(),
"src/main.rs"
);
assert_eq!(
require_path_arg(&serde_json::json!({"file": "lib.rs"})).unwrap(),
"lib.rs"
);
assert_eq!(
require_path_arg(&serde_json::json!({"filename": "src/lib.rs"})).unwrap(),
"src/lib.rs"
);
assert_eq!(
require_path_arg(&serde_json::json!({"path": "main.rs", "file": "other.rs"})).unwrap(),
"main.rs"
);
assert_eq!(
require_path_arg(&serde_json::json!({"file": "a.rs", "filename": "b.rs"})).unwrap(),
"a.rs"
);
let err = require_path_arg(&serde_json::json!({"other": "value"})).unwrap_err();
assert!(err.to_string().contains("path"));
let err = require_path_arg(&serde_json::json!({"path": ["invalid"], "file": "real.rs"}))
.unwrap_err();
assert!(err.to_string().contains("path"));
}
#[tokio::test]
async fn save_generated_file_creates_file() {
let tmp = TempDir::new().expect("tempdir");
let ws = Workspace {
name: "test".into(),
path: tmp.path().to_string_lossy().to_string(),
status: "ready".into(),
created_at: String::new(),
updated_at: String::new(),
maintenance: false,
paused: false,
maintainer_debounce_mins: 5,
maintainer_last_run_at: None,
diagnostics: None,
diagnostics_updated_at: None,
};
let data = b"hello world";
let path = save_generated_file(&ws, data, "img", "png")
.await
.expect("save_generated_file should succeed");
assert!(path.exists(), "file should exist: {}", path.display());
let content = tokio::fs::read_to_string(&path).await.unwrap();
assert_eq!(content, "hello world");
let file_name = path.file_name().unwrap().to_str().unwrap();
assert!(
file_name.starts_with("img_"),
"filename should start with 'img_': {file_name}",
);
assert!(
std::path::Path::new(file_name)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("png")),
"filename should end with '.png': {file_name}",
);
let _ = tokio::fs::remove_dir_all(tmp.path()).await;
}
#[tokio::test]
async fn save_generated_file_creates_directory_if_missing() {
let tmp = TempDir::new().expect("tempdir");
let ws = Workspace {
name: "test".into(),
path: tmp.path().join("nested").to_string_lossy().to_string(),
status: "ready".into(),
created_at: String::new(),
updated_at: String::new(),
maintenance: false,
paused: false,
maintainer_debounce_mins: 5,
maintainer_last_run_at: None,
diagnostics: None,
diagnostics_updated_at: None,
};
let data = b"test content";
let path = save_generated_file(&ws, data, "vid", "mp4")
.await
.expect("save_generated_file should create dirs");
assert!(path.exists(), "file should exist: {}", path.display());
assert!(
path.starts_with(tmp.path().join("nested")),
"file should be inside workspace"
);
let _ = tokio::fs::remove_dir_all(tmp.path()).await;
}
}