use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ParamDef {
pub name: String,
#[serde(rename = "type")]
pub param_type: String,
pub description: String,
pub required: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolDef {
pub name: String,
pub description: String,
pub parameters: Vec<ParamDef>,
}
impl ToolDef {
pub fn from_markdown(content: &str) -> Result<Self, String> {
let content = content.trim();
if !content.starts_with("---") {
return Err("Tool markdown must start with --- frontmatter delimiter".into());
}
let after_first = &content[3..];
let end_idx = after_first
.find("---")
.ok_or("Missing closing --- frontmatter delimiter")?;
let frontmatter = after_first[..end_idx].trim();
parse_tool_frontmatter(frontmatter)
}
pub fn from_file(path: &Path) -> Result<Self, String> {
let content =
std::fs::read_to_string(path).map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
Self::from_markdown(&content)
}
pub fn to_openai_json(&self) -> serde_json::Value {
let mut properties = serde_json::Map::new();
let mut required = Vec::new();
for param in &self.parameters {
properties.insert(
param.name.clone(),
serde_json::json!({
"type": param.param_type,
"description": param.description
}),
);
if param.required {
required.push(serde_json::Value::String(param.name.clone()));
}
}
serde_json::json!({
"type": "function",
"function": {
"name": self.name,
"description": self.description,
"parameters": {
"type": "object",
"properties": properties,
"required": required
}
}
})
}
}
#[derive(Debug, Clone)]
pub struct Toolkit {
pub tools: Vec<ToolDef>,
tools_by_name: HashMap<String, usize>,
}
impl Toolkit {
pub fn new(tools: Vec<ToolDef>) -> Self {
let tools_by_name = tools
.iter()
.enumerate()
.map(|(i, t)| (t.name.clone(), i))
.collect();
Self { tools, tools_by_name }
}
pub fn from_dir(dir: &Path) -> Result<Self, String> {
let mut tools = Vec::new();
let entries = std::fs::read_dir(dir)
.map_err(|e| format!("Failed to read directory {}: {}", dir.display(), e))?;
let mut paths: Vec<_> = entries
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().map_or(false, |ext| ext == "md"))
.collect();
paths.sort();
for path in paths {
tools.push(ToolDef::from_file(&path)?);
}
Ok(Self::new(tools))
}
pub fn get(&self, name: &str) -> Option<&ToolDef> {
self.tools_by_name.get(name).map(|&i| &self.tools[i])
}
pub fn tool_names(&self) -> Vec<String> {
self.tools.iter().map(|t| t.name.clone()).collect()
}
pub fn to_openai_json(&self) -> Vec<serde_json::Value> {
self.tools.iter().map(|t| t.to_openai_json()).collect()
}
pub fn to_prompt_listing(&self) -> String {
let mut out = String::new();
for tool in &self.tools {
out.push_str(&format!("### {}\n", tool.name));
out.push_str(&format!("{}\n", tool.description));
if !tool.parameters.is_empty() {
out.push_str("Parameters:\n");
for p in &tool.parameters {
let req = if p.required { " (required)" } else { "" };
out.push_str(&format!(
" - `{}` ({}): {}{}\n",
p.name, p.param_type, p.description, req
));
}
}
out.push('\n');
}
out
}
}
#[derive(Debug, Clone)]
pub struct PromptTemplate {
pub template: String,
}
impl PromptTemplate {
pub fn new(template: impl Into<String>) -> Self {
Self {
template: template.into(),
}
}
pub fn from_file(path: &Path) -> Result<Self, String> {
let content =
std::fs::read_to_string(path).map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
Ok(Self::new(content))
}
pub fn render(&self, toolkit: &Toolkit) -> String {
self.template
.replace("{{tools}}", &toolkit.to_prompt_listing())
}
pub fn render_with(&self, vars: &HashMap<String, String>) -> String {
let mut result = self.template.clone();
for (key, value) in vars {
result = result.replace(&format!("{{{{{}}}}}", key), value);
}
result
}
}
fn parse_tool_frontmatter(frontmatter: &str) -> Result<ToolDef, String> {
let mut name = String::new();
let mut description = String::new();
let mut parameters = Vec::new();
let mut in_parameters = false;
let mut current_param: Option<ParamBuilder> = None;
for line in frontmatter.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if !line.starts_with(' ') && !line.starts_with('\t') {
if let Some(pb) = current_param.take() {
parameters.push(pb.build()?);
}
if let Some(val) = trimmed.strip_prefix("name:") {
name = val.trim().to_string();
in_parameters = false;
} else if let Some(val) = trimmed.strip_prefix("description:") {
description = val.trim().to_string();
in_parameters = false;
} else if trimmed == "parameters:" {
in_parameters = true;
}
continue;
}
if !in_parameters {
continue;
}
let stripped = trimmed.trim_start_matches('-').trim();
if trimmed.starts_with('-') {
if let Some(pb) = current_param.take() {
parameters.push(pb.build()?);
}
let mut pb = ParamBuilder::default();
if let Some(val) = stripped.strip_prefix("name:") {
pb.name = Some(val.trim().to_string());
}
current_param = Some(pb);
} else if let Some(ref mut pb) = current_param {
if let Some(val) = stripped.strip_prefix("name:") {
pb.name = Some(val.trim().to_string());
} else if let Some(val) = stripped.strip_prefix("type:") {
pb.param_type = Some(val.trim().to_string());
} else if let Some(val) = stripped.strip_prefix("description:") {
pb.description = Some(val.trim().to_string());
} else if let Some(val) = stripped.strip_prefix("required:") {
pb.required = Some(val.trim() == "true");
}
}
}
if let Some(pb) = current_param.take() {
parameters.push(pb.build()?);
}
if name.is_empty() {
return Err("Tool frontmatter missing 'name' field".into());
}
if description.is_empty() {
return Err("Tool frontmatter missing 'description' field".into());
}
Ok(ToolDef {
name,
description,
parameters,
})
}
#[derive(Default)]
struct ParamBuilder {
name: Option<String>,
param_type: Option<String>,
description: Option<String>,
required: Option<bool>,
}
impl ParamBuilder {
fn build(self) -> Result<ParamDef, String> {
Ok(ParamDef {
name: self.name.ok_or("Parameter missing 'name'")?,
param_type: self.param_type.unwrap_or_else(|| "string".into()),
description: self.description.unwrap_or_default(),
required: self.required.unwrap_or(false),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_tool_from_markdown() {
let md = r#"---
name: getWeather
description: Get current weather for a location
parameters:
- name: location
type: string
description: The city name
required: true
---
# getWeather
Extra docs here.
"#;
let tool = ToolDef::from_markdown(md).unwrap();
assert_eq!(tool.name, "getWeather");
assert_eq!(tool.parameters.len(), 1);
assert_eq!(tool.parameters[0].name, "location");
assert!(tool.parameters[0].required);
}
#[test]
fn prompt_template_renders_tools() {
let toolkit = Toolkit::new(vec![ToolDef {
name: "myTool".into(),
description: "Does stuff".into(),
parameters: vec![],
}]);
let tpl = PromptTemplate::new("You have these tools:\n{{tools}}\nUse them wisely.");
let rendered = tpl.render(&toolkit);
assert!(rendered.contains("myTool"));
assert!(rendered.contains("Does stuff"));
assert!(!rendered.contains("{{tools}}"));
}
}