use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use crate::tool_category::ToolCategory;
use crate::tool_value_model::ToolValueModel;
pub trait ToolEnricher: Send + Sync {
fn supported_categories(&self) -> &[ToolCategory];
fn enrich_schema(&self, tool_name: &str, schema: &mut ToolSchema);
fn transform_args(&self, tool_name: &str, args: &mut Value);
fn value_model(&self, _tool_name: &str) -> Option<ToolValueModel> {
None
}
fn project_args(
&self,
_prev_tool: &str,
_prev_result: &Value,
_link: &crate::tool_value_model::FollowUpLink,
) -> Option<Value> {
None
}
fn rate_limit_host(&self, _tool_name: &str, _args: &Value) -> Option<String> {
None
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PropertySchema {
#[serde(rename = "type")]
pub schema_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(rename = "enum", skip_serializing_if = "Option::is_none")]
pub enum_values: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub minimum: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub maximum: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub items: Option<Box<PropertySchema>>,
#[serde(rename = "x-enriched", skip_serializing_if = "Option::is_none")]
pub enriched: Option<bool>,
}
impl PropertySchema {
pub fn string(description: &str) -> Self {
Self {
schema_type: "string".into(),
description: Some(description.into()),
..Default::default()
}
}
pub fn string_enum(values: &[&str], description: &str) -> Self {
Self {
schema_type: "string".into(),
description: Some(description.into()),
enum_values: Some(values.iter().map(|s| s.to_string()).collect()),
enriched: Some(true),
..Default::default()
}
}
pub fn number(description: &str) -> Self {
Self {
schema_type: "number".into(),
description: Some(description.into()),
..Default::default()
}
}
pub fn integer(description: &str, min: Option<f64>, max: Option<f64>) -> Self {
Self {
schema_type: "integer".into(),
description: Some(description.into()),
minimum: min,
maximum: max,
..Default::default()
}
}
pub fn boolean(description: &str) -> Self {
Self {
schema_type: "boolean".into(),
description: Some(description.into()),
..Default::default()
}
}
pub fn array(items: PropertySchema, description: &str) -> Self {
Self {
schema_type: "array".into(),
description: Some(description.into()),
items: Some(Box::new(items)),
..Default::default()
}
}
}
impl Default for PropertySchema {
fn default() -> Self {
Self {
schema_type: "string".into(),
description: None,
enum_values: None,
default: None,
minimum: None,
maximum: None,
items: None,
enriched: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolSchema {
pub properties: HashMap<String, PropertySchema>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub required: Vec<String>,
}
impl ToolSchema {
pub fn new() -> Self {
Self {
properties: HashMap::new(),
required: Vec::new(),
}
}
pub fn from_json(schema: &Value) -> Self {
serde_json::from_value::<ToolSchema>(schema.clone()).unwrap_or_else(|_| {
let properties = schema
.get("properties")
.and_then(|p| {
serde_json::from_value::<HashMap<String, PropertySchema>>(p.clone()).ok()
})
.unwrap_or_default();
let required = schema
.get("required")
.and_then(|r| r.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
Self {
properties,
required,
}
})
}
pub fn to_json(&self) -> Value {
let mut schema = serde_json::json!({
"type": "object",
"properties": self.properties,
});
if !self.required.is_empty() {
schema["required"] = serde_json::json!(self.required);
}
schema
}
pub fn add_enum_param(&mut self, name: &str, values: &[&str], description: &str) {
self.properties.insert(
name.into(),
PropertySchema::string_enum(values, description),
);
}
pub fn set_enum(&mut self, param: &str, values: &[String]) {
if let Some(prop) = self.properties.get_mut(param) {
prop.enum_values = Some(values.to_vec());
prop.enriched = Some(true);
}
}
pub fn add_property(&mut self, name: &str, prop: PropertySchema) {
self.properties.insert(name.into(), prop);
}
pub fn add_param(&mut self, name: &str, schema: Value) {
if let Ok(prop) = serde_json::from_value::<PropertySchema>(schema) {
self.properties.insert(name.into(), prop);
}
}
pub fn remove_params(&mut self, names: &[&str]) {
for name in names {
self.properties.remove(*name);
self.required.retain(|r| r != *name);
}
}
pub fn set_required(&mut self, param: &str, required: bool) {
if required {
if !self.required.contains(¶m.to_string()) {
self.required.push(param.into());
}
} else {
self.required.retain(|r| r != param);
}
}
pub fn set_description(&mut self, param: &str, desc: &str) {
if let Some(prop) = self.properties.get_mut(param) {
prop.description = Some(desc.into());
}
}
pub fn set_default(&mut self, param: &str, value: Value) {
if let Some(prop) = self.properties.get_mut(param) {
prop.default = Some(value);
}
}
}
impl Default for ToolSchema {
fn default() -> Self {
Self::new()
}
}
pub fn sanitize_field_name(name: &str) -> String {
let sanitized: String = name
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() {
c.to_ascii_lowercase()
} else {
'_'
}
})
.collect();
let collapsed = sanitized
.split('_')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("_");
format!("cf_{collapsed}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sanitize_field_name() {
assert_eq!(sanitize_field_name("Story Points"), "cf_story_points");
assert_eq!(sanitize_field_name("Risk Level"), "cf_risk_level");
assert_eq!(
sanitize_field_name("My Custom Field!"),
"cf_my_custom_field"
);
assert_eq!(sanitize_field_name("simple"), "cf_simple");
assert_eq!(sanitize_field_name("Приоритет"), "cf_");
}
#[test]
fn test_property_schema_constructors() {
let s = PropertySchema::string("A description");
assert_eq!(s.schema_type, "string");
assert_eq!(s.description.as_deref(), Some("A description"));
let e = PropertySchema::string_enum(&["a", "b"], "Pick one");
assert_eq!(e.enum_values, Some(vec!["a".to_string(), "b".to_string()]));
assert_eq!(e.enriched, Some(true));
let n = PropertySchema::number("Count");
assert_eq!(n.schema_type, "number");
let i = PropertySchema::integer("Limit", Some(1.0), Some(100.0));
assert_eq!(i.minimum, Some(1.0));
assert_eq!(i.maximum, Some(100.0));
let b = PropertySchema::boolean("Flag");
assert_eq!(b.schema_type, "boolean");
let a = PropertySchema::array(PropertySchema::string("item"), "List");
assert_eq!(a.schema_type, "array");
assert!(a.items.is_some());
}
#[test]
fn test_tool_schema_add_enum_param() {
let mut schema = ToolSchema::new();
schema.add_enum_param("status", &["open", "closed"], "Issue status");
let prop = schema.properties.get("status").unwrap();
assert_eq!(prop.schema_type, "string");
assert_eq!(
prop.enum_values,
Some(vec!["open".to_string(), "closed".to_string()])
);
assert_eq!(prop.enriched, Some(true));
}
#[test]
fn test_tool_schema_remove_params() {
let mut schema = ToolSchema::from_json(&serde_json::json!({
"type": "object",
"properties": {
"title": { "type": "string" },
"priority": { "type": "string" },
},
"required": ["title", "priority"],
}));
schema.remove_params(&["priority"]);
assert!(!schema.properties.contains_key("priority"));
assert_eq!(schema.required, vec!["title"]);
}
#[test]
fn test_tool_schema_roundtrip() {
let mut schema = ToolSchema::new();
schema.add_property("title", PropertySchema::string("Title"));
schema.set_required("title", true);
let json = schema.to_json();
assert_eq!(json["properties"]["title"]["type"], "string");
assert_eq!(json["required"], serde_json::json!(["title"]));
let restored = ToolSchema::from_json(&json);
assert!(restored.properties.contains_key("title"));
assert_eq!(restored.required, vec!["title"]);
}
#[test]
fn test_tool_schema_set_enum() {
let mut schema = ToolSchema::new();
schema.add_property("state", PropertySchema::string("Filter by state"));
schema.set_enum(
"state",
&["opened".into(), "closed".into(), "merged".into()],
);
let state = schema.properties.get("state").unwrap();
assert_eq!(
state.enum_values,
Some(vec![
"opened".to_string(),
"closed".to_string(),
"merged".to_string()
])
);
assert_eq!(state.enriched, Some(true));
assert_eq!(state.description.as_deref(), Some("Filter by state"));
}
#[test]
fn test_tool_schema_set_required() {
let mut schema = ToolSchema::new();
schema.required = vec!["title".into()];
schema.set_required("description", true);
assert_eq!(schema.required, vec!["title", "description"]);
schema.set_required("title", false);
assert_eq!(schema.required, vec!["description"]);
schema.set_required("description", true);
assert_eq!(schema.required, vec!["description"]);
}
#[test]
fn test_tool_schema_set_default() {
let mut schema = ToolSchema::new();
schema.add_property("limit", PropertySchema::integer("Max results", None, None));
schema.set_default("limit", serde_json::json!(20));
assert_eq!(
schema.properties.get("limit").unwrap().default,
Some(serde_json::json!(20))
);
}
#[test]
fn test_tool_schema_add_param_from_json() {
let mut schema = ToolSchema::new();
schema.add_param(
"cf_risk",
serde_json::json!({
"type": "string",
"enum": ["Low", "Medium", "High"],
"description": "Risk level",
"x-enriched": true,
}),
);
let prop = schema.properties.get("cf_risk").unwrap();
assert_eq!(prop.schema_type, "string");
assert_eq!(
prop.enum_values,
Some(vec![
"Low".to_string(),
"Medium".to_string(),
"High".to_string()
])
);
}
#[test]
fn test_from_json_backward_compat() {
let json = serde_json::json!({
"type": "object",
"properties": {
"state": {
"type": "string",
"enum": ["open", "closed"],
"description": "Issue state"
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100
}
},
"required": ["state"]
});
let schema = ToolSchema::from_json(&json);
assert_eq!(schema.properties.len(), 2);
assert_eq!(schema.required, vec!["state"]);
let state = schema.properties.get("state").unwrap();
assert_eq!(state.schema_type, "string");
assert_eq!(
state.enum_values,
Some(vec!["open".to_string(), "closed".to_string()])
);
let limit = schema.properties.get("limit").unwrap();
assert_eq!(limit.schema_type, "integer");
assert_eq!(limit.minimum, Some(1.0));
assert_eq!(limit.maximum, Some(100.0));
}
}