use super::media::{context::MediaToolContext, create_media_tool_adapters};
use super::{
create_file_tool_adapters, AssertTool, BuiltinTool, CompleteTool, EmitTool, LogTool,
PromptTool, RunTool, SleepTool,
};
use crate::error::NikaError;
use crate::tools::ToolContext;
use rustc_hash::FxHashMap;
use std::sync::Arc;
pub struct BuiltinToolRouter {
tools: FxHashMap<&'static str, Arc<dyn BuiltinTool>>,
}
impl BuiltinToolRouter {
pub fn new() -> Self {
let mut tools: FxHashMap<&'static str, Arc<dyn BuiltinTool>> = FxHashMap::default();
tools.insert("sleep", Arc::new(SleepTool));
tools.insert("log", Arc::new(LogTool));
tools.insert("emit", Arc::new(EmitTool));
tools.insert("assert", Arc::new(AssertTool));
tools.insert("prompt", Arc::new(PromptTool::default()));
tools.insert("run", Arc::new(RunTool));
tools.insert("complete", Arc::new(CompleteTool));
Self { tools }
}
pub fn with_file_tools(ctx: Arc<ToolContext>) -> Self {
let mut router = Self::new();
for tool in create_file_tool_adapters(ctx) {
router.tools.insert(tool.name(), Arc::from(tool));
}
router
}
pub fn with_all_tools(file_ctx: Arc<ToolContext>, media_ctx: Arc<MediaToolContext>) -> Self {
let mut router = Self::with_file_tools(file_ctx);
for tool in create_media_tool_adapters(media_ctx) {
router.tools.insert(tool.name(), Arc::from(tool));
}
router
}
#[inline]
pub fn is_builtin(tool_name: &str) -> bool {
tool_name.starts_with("nika:")
}
#[inline]
pub fn extract_name(tool_name: &str) -> Option<&str> {
tool_name.strip_prefix("nika:")
}
pub fn has_tool(&self, name: &str) -> bool {
self.tools.contains_key(name)
}
pub fn tool_names(&self) -> Vec<&'static str> {
self.tools.keys().copied().collect()
}
pub fn register<T: BuiltinTool + 'static>(&mut self, tool: T) {
self.tools.insert(tool.name(), Arc::new(tool));
}
pub async fn dispatch(&self, tool_name: &str, args: String) -> Result<String, NikaError> {
let name = Self::extract_name(tool_name).ok_or_else(|| NikaError::BuiltinToolError {
tool: tool_name.into(),
reason: "Not a builtin tool (missing nika: prefix)".into(),
})?;
let tool = self
.tools
.get(name)
.ok_or_else(|| NikaError::BuiltinToolError {
tool: tool_name.into(),
reason: format!("Unknown builtin tool: {}", name),
})?;
tool.call(args).await
}
}
impl Default for BuiltinToolRouter {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::PermissionMode;
use tempfile::TempDir;
fn setup_test_context() -> (TempDir, Arc<ToolContext>) {
let temp_dir = TempDir::new().unwrap();
let ctx = Arc::new(ToolContext::new(
temp_dir.path().to_path_buf(),
PermissionMode::YoloMode,
));
(temp_dir, ctx)
}
#[test]
fn test_router_is_builtin() {
assert!(BuiltinToolRouter::is_builtin("nika:sleep"));
assert!(BuiltinToolRouter::is_builtin("nika:log"));
assert!(BuiltinToolRouter::is_builtin("nika:emit"));
assert!(BuiltinToolRouter::is_builtin("nika:assert"));
assert!(BuiltinToolRouter::is_builtin("nika:prompt"));
assert!(BuiltinToolRouter::is_builtin("nika:run"));
assert!(BuiltinToolRouter::is_builtin("nika:read"));
assert!(BuiltinToolRouter::is_builtin("nika:write"));
assert!(BuiltinToolRouter::is_builtin("nika:edit"));
assert!(BuiltinToolRouter::is_builtin("nika:glob"));
assert!(BuiltinToolRouter::is_builtin("nika:grep"));
assert!(!BuiltinToolRouter::is_builtin("novanet:describe"));
assert!(!BuiltinToolRouter::is_builtin("sleep"));
assert!(!BuiltinToolRouter::is_builtin(""));
}
#[test]
fn test_router_extract_name() {
assert_eq!(BuiltinToolRouter::extract_name("nika:sleep"), Some("sleep"));
assert_eq!(BuiltinToolRouter::extract_name("nika:log"), Some("log"));
assert_eq!(BuiltinToolRouter::extract_name("nika:emit"), Some("emit"));
assert_eq!(
BuiltinToolRouter::extract_name("nika:assert"),
Some("assert")
);
assert_eq!(
BuiltinToolRouter::extract_name("nika:prompt"),
Some("prompt")
);
assert_eq!(BuiltinToolRouter::extract_name("nika:run"), Some("run"));
assert_eq!(BuiltinToolRouter::extract_name("novanet:x"), None);
assert_eq!(BuiltinToolRouter::extract_name("sleep"), None);
assert_eq!(BuiltinToolRouter::extract_name(""), None);
}
#[test]
fn test_router_new_has_6_core_tools() {
let router = BuiltinToolRouter::new();
assert!(router.has_tool("sleep"));
assert!(router.has_tool("log"));
assert!(router.has_tool("emit"));
assert!(router.has_tool("assert"));
assert!(router.has_tool("prompt"));
assert!(router.has_tool("run"));
assert!(router.has_tool("complete"));
assert!(!router.has_tool("read"));
assert!(!router.has_tool("write"));
assert_eq!(router.tool_names().len(), 7); }
#[test]
fn test_router_with_file_tools_has_12_tools() {
let (_temp, ctx) = setup_test_context();
let router = BuiltinToolRouter::with_file_tools(ctx);
assert!(router.has_tool("sleep"));
assert!(router.has_tool("log"));
assert!(router.has_tool("emit"));
assert!(router.has_tool("assert"));
assert!(router.has_tool("prompt"));
assert!(router.has_tool("run"));
assert!(router.has_tool("complete"));
assert!(router.has_tool("read"));
assert!(router.has_tool("write"));
assert!(router.has_tool("edit"));
assert!(router.has_tool("glob"));
assert!(router.has_tool("grep"));
assert_eq!(router.tool_names().len(), 12); }
#[test]
fn test_router_register_tool() {
struct TestTool;
impl BuiltinTool for TestTool {
fn name(&self) -> &'static str {
"test"
}
fn call<'a>(
&'a self,
_args: String,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = Result<String, NikaError>> + Send + 'a>,
> {
Box::pin(async { Ok("test result".to_string()) })
}
}
let mut router = BuiltinToolRouter::new();
router.register(TestTool);
assert!(router.has_tool("test"));
assert!(!router.has_tool("unknown"));
}
#[tokio::test]
async fn test_router_dispatch_registered_tool() {
struct TestTool;
impl BuiltinTool for TestTool {
fn name(&self) -> &'static str {
"test"
}
fn call<'a>(
&'a self,
args: String,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = Result<String, NikaError>> + Send + 'a>,
> {
Box::pin(async move { Ok(format!("received: {}", args)) })
}
}
let mut router = BuiltinToolRouter::new();
router.register(TestTool);
let result = router
.dispatch("nika:test", r#"{"hello":"world"}"#.to_string())
.await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), r#"received: {"hello":"world"}"#);
}
#[tokio::test]
async fn test_router_dispatch_unknown_tool() {
let router = BuiltinToolRouter::new();
let result = router.dispatch("nika:unknown", "{}".to_string()).await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("Unknown builtin tool"));
}
#[tokio::test]
async fn test_router_dispatch_not_builtin() {
let router = BuiltinToolRouter::new();
let result = router.dispatch("novanet:describe", "{}".to_string()).await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("Not a builtin tool"));
}
#[test]
fn test_router_default() {
let router = BuiltinToolRouter::default();
assert_eq!(router.tool_names().len(), 7);
}
#[tokio::test]
async fn test_router_dispatch_sleep() {
let router = BuiltinToolRouter::new();
let result = router
.dispatch("nika:sleep", r#"{"duration":"1ms"}"#.to_string())
.await;
assert!(result.is_ok());
let response: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
assert_eq!(response["slept_for_ms"], 1);
}
#[tokio::test]
async fn test_router_dispatch_log() {
let router = BuiltinToolRouter::new();
let result = router
.dispatch(
"nika:log",
r#"{"level":"info","message":"test"}"#.to_string(),
)
.await;
assert!(result.is_ok());
let response: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
assert_eq!(response["logged"], true);
}
#[tokio::test]
async fn test_router_dispatch_emit() {
let router = BuiltinToolRouter::new();
let result = router
.dispatch(
"nika:emit",
r#"{"name":"test_event","payload":{}}"#.to_string(),
)
.await;
assert!(result.is_ok());
let response: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
assert_eq!(response["emitted"], true);
}
#[tokio::test]
async fn test_router_dispatch_assert_true() {
let router = BuiltinToolRouter::new();
let result = router
.dispatch("nika:assert", r#"{"condition":true}"#.to_string())
.await;
assert!(result.is_ok());
let response: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
assert_eq!(response["passed"], true);
}
#[tokio::test]
async fn test_router_dispatch_assert_false() {
let router = BuiltinToolRouter::new();
let result = router
.dispatch("nika:assert", r#"{"condition":false}"#.to_string())
.await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("Assertion failed"));
}
#[tokio::test]
async fn test_router_dispatch_prompt_headless() {
let router = BuiltinToolRouter::new();
let result = router
.dispatch(
"nika:prompt",
r#"{"message":"Test?","default":"yes"}"#.to_string(),
)
.await;
assert!(result.is_ok());
let response: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
assert_eq!(response["response"], "yes");
assert_eq!(response["default_used"], true);
}
#[tokio::test]
async fn test_router_dispatch_run_nonexistent_file() {
let router = BuiltinToolRouter::new();
let result = router
.dispatch("nika:run", r#"{"workflow":"test.nika.yaml"}"#.to_string())
.await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.to_string().contains("resolve workflow path")
|| err.to_string().contains("not found")
);
}
#[tokio::test]
async fn test_router_dispatch_write_then_read() {
let (temp_dir, ctx) = setup_test_context();
let router = BuiltinToolRouter::with_file_tools(ctx);
let file_path = temp_dir.path().join("test.txt");
let write_args = serde_json::json!({
"file_path": file_path.to_string_lossy(),
"content": "Hello from router!"
})
.to_string();
let result = router.dispatch("nika:write", write_args).await;
assert!(result.is_ok(), "Write failed: {:?}", result);
let read_args = serde_json::json!({
"file_path": file_path.to_string_lossy()
})
.to_string();
let result = router.dispatch("nika:read", read_args).await;
assert!(result.is_ok(), "Read failed: {:?}", result);
assert!(result.unwrap().contains("Hello from router!"));
}
#[tokio::test]
async fn test_router_dispatch_glob() {
let (temp_dir, ctx) = setup_test_context();
let router = BuiltinToolRouter::with_file_tools(ctx);
std::fs::write(temp_dir.path().join("a.txt"), "a").unwrap();
std::fs::write(temp_dir.path().join("b.txt"), "b").unwrap();
std::fs::write(temp_dir.path().join("c.md"), "c").unwrap();
let glob_args = serde_json::json!({
"pattern": "*.txt",
"path": temp_dir.path().to_string_lossy()
})
.to_string();
let result = router.dispatch("nika:glob", glob_args).await;
assert!(result.is_ok());
let output = result.unwrap();
assert!(output.contains("a.txt"));
assert!(output.contains("b.txt"));
assert!(!output.contains("c.md"));
}
#[tokio::test]
async fn test_router_dispatch_grep() {
let (temp_dir, ctx) = setup_test_context();
let router = BuiltinToolRouter::with_file_tools(ctx);
std::fs::write(
temp_dir.path().join("search.txt"),
"Line 1: foo\nLine 2: bar\nLine 3: foo bar",
)
.unwrap();
let grep_args = serde_json::json!({
"pattern": "foo",
"path": temp_dir.path().to_string_lossy()
})
.to_string();
let result = router.dispatch("nika:grep", grep_args).await;
assert!(result.is_ok());
assert!(result.unwrap().contains("search.txt"));
}
#[tokio::test]
async fn test_router_dispatch_file_tool_not_found_without_context() {
let router = BuiltinToolRouter::new();
let result = router
.dispatch(
"nika:write",
r#"{"file_path":"x","content":"y"}"#.to_string(),
)
.await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("Unknown builtin tool"));
}
}