use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use tracing::{debug, info};
#[cfg(feature = "python")]
use super::python::PythonTool;
use super::{registry::ToolRegistry, Tool};
use crate::llm::ToolSpec;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolConfig {
pub name: String,
pub schema: PathBuf,
pub implementation: ToolImplementation,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum ToolImplementation {
Rust {
module: String,
},
Python {
script: PathBuf,
},
}
pub fn load_tools(configs: &[ToolConfig], base_path: &Path, registry: &ToolRegistry) -> Result<()> {
info!("Loading {} tools", configs.len());
for config in configs {
load_tool(config, base_path, registry)
.with_context(|| format!("Failed to load tool: {}", config.name))?;
}
info!("Successfully loaded {} tools", configs.len());
Ok(())
}
fn load_tool(config: &ToolConfig, base_path: &Path, registry: &ToolRegistry) -> Result<()> {
debug!(tool = %config.name, "Loading tool");
let schema_path = if config.schema.is_absolute() {
config.schema.clone()
} else {
base_path.join(&config.schema)
};
let schema_content = std::fs::read_to_string(&schema_path)
.with_context(|| format!("Failed to read schema: {}", schema_path.display()))?;
let spec: ToolSpec = serde_json::from_str(&schema_content)
.with_context(|| format!("Failed to parse schema: {}", schema_path.display()))?;
if spec.name != config.name {
return Err(anyhow!(
"Tool name mismatch: config says '{}' but schema says '{}'",
config.name,
spec.name
));
}
let tool: Arc<dyn Tool> = match &config.implementation {
ToolImplementation::Rust { module } => load_rust_tool(&config.name, module, spec)?,
#[cfg(feature = "python")]
ToolImplementation::Python { script } => {
let script_path = if script.is_absolute() {
script.clone()
} else {
base_path.join(script)
};
Arc::new(PythonTool::new(config.name.clone(), script_path, spec)?)
}
#[cfg(not(feature = "python"))]
ToolImplementation::Python { .. } => {
return Err(anyhow!(
"Python tool '{}' cannot be loaded: Python support not enabled. Rebuild with --features python",
config.name
));
}
};
registry.register(tool);
debug!(tool = %config.name, "Tool loaded successfully");
Ok(())
}
fn load_rust_tool(name: &str, module: &str, spec: ToolSpec) -> Result<Arc<dyn Tool>> {
let _ = (name, module, spec);
Err(anyhow!(
"TOML-configured Rust tool loading is experimental and currently disabled. \
Register Rust tools programmatically instead."
))
}
#[allow(dead_code)]
fn resolve_builtin_rust_tool(_name: &str, _module: &str, _spec: ToolSpec) -> Option<Arc<dyn Tool>> {
None
}
pub fn validate_tool_configs(configs: &[ToolConfig], base_path: &Path) -> Result<()> {
for config in configs {
let schema_path = if config.schema.is_absolute() {
config.schema.clone()
} else {
base_path.join(&config.schema)
};
if !schema_path.exists() {
return Err(anyhow!(
"Schema file not found for tool '{}': {}",
config.name,
schema_path.display()
));
}
let schema_content = std::fs::read_to_string(&schema_path)?;
let spec: ToolSpec = serde_json::from_str(&schema_content)
.with_context(|| format!("Invalid schema for tool '{}'", config.name))?;
if spec.name != config.name {
return Err(anyhow!(
"Tool name mismatch in '{}': config says '{}' but schema says '{}'",
schema_path.display(),
config.name,
spec.name
));
}
match &config.implementation {
#[cfg(feature = "python")]
ToolImplementation::Python { script } => {
let script_path = if script.is_absolute() {
script.clone()
} else {
base_path.join(script)
};
if !script_path.exists() {
return Err(anyhow!(
"Python script not found for tool '{}': {}",
config.name,
script_path.display()
));
}
}
#[cfg(not(feature = "python"))]
ToolImplementation::Python { .. } => {
return Err(anyhow!(
"Python tool '{}' cannot be validated: Python support not enabled",
config.name
));
}
ToolImplementation::Rust { module } => {
let _ = module;
return Err(anyhow!(
"Rust tool '{}' uses a disabled experimental TOML feature. Register Rust tools programmatically instead.",
config.name
));
}
}
}
Ok(())
}
#[cfg(all(test, feature = "python"))]
mod tests {
use super::*;
use serde_json::json;
use std::io::Write;
use tempfile::TempDir;
fn create_test_env() -> (TempDir, PathBuf, PathBuf) {
let dir = TempDir::new().unwrap();
let base = dir.path().to_path_buf();
let schema_path = base.join("echo.json");
let mut schema_file = std::fs::File::create(&schema_path).unwrap();
schema_file
.write_all(
json!({
"type": "function",
"name": "echo",
"description": "Echo tool",
"parameters": {
"type": "object",
"properties": {
"message": {"type": "string"}
}
}
})
.to_string()
.as_bytes(),
)
.unwrap();
let script_path = base.join("echo.py");
let mut script_file = std::fs::File::create(&script_path).unwrap();
script_file
.write_all(b"def execute(args):\n return {'output': args.get('message', '')}")
.unwrap();
(dir, schema_path, script_path)
}
#[test]
fn test_validate_tool_configs() {
let (_dir, schema_path, _script_path) = create_test_env();
let base = schema_path.parent().unwrap();
let configs = vec![ToolConfig {
name: "echo".to_string(),
schema: "echo.json".into(),
implementation: ToolImplementation::Python {
script: "echo.py".into(),
},
}];
assert!(validate_tool_configs(&configs, base).is_ok());
}
#[test]
fn test_validate_missing_schema() {
let dir = TempDir::new().unwrap();
let configs = vec![ToolConfig {
name: "echo".to_string(),
schema: "nonexistent.json".into(),
implementation: ToolImplementation::Python {
script: "echo.py".into(),
},
}];
let result = validate_tool_configs(&configs, dir.path());
assert!(result.is_err());
}
#[test]
fn test_load_python_tool() {
let (_dir, schema_path, _script_path) = create_test_env();
let base = schema_path.parent().unwrap();
let registry = ToolRegistry::new();
let configs = vec![ToolConfig {
name: "echo".to_string(),
schema: "echo.json".into(),
implementation: ToolImplementation::Python {
script: "echo.py".into(),
},
}];
load_tools(&configs, base, ®istry).unwrap();
assert_eq!(registry.len(), 1);
assert!(registry.resolve("echo").is_some());
}
}