1use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use std::collections::HashMap;
10
11use crate::tool_category::ToolCategory;
12use crate::tool_value_model::ToolValueModel;
13
14pub trait ToolEnricher: Send + Sync {
20 fn supported_categories(&self) -> &[ToolCategory];
23
24 fn enrich_schema(&self, tool_name: &str, schema: &mut ToolSchema);
26
27 fn transform_args(&self, tool_name: &str, args: &mut Value);
29
30 fn value_model(&self, _tool_name: &str) -> Option<ToolValueModel> {
38 None
39 }
40
41 fn project_args(
61 &self,
62 _prev_tool: &str,
63 _prev_result: &Value,
64 _link: &crate::tool_value_model::FollowUpLink,
65 ) -> Option<Value> {
66 None
67 }
68
69 fn rate_limit_host(&self, _tool_name: &str, _args: &Value) -> Option<String> {
82 None
83 }
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct PropertySchema {
89 #[serde(rename = "type")]
91 pub schema_type: String,
92
93 #[serde(skip_serializing_if = "Option::is_none")]
95 pub description: Option<String>,
96
97 #[serde(rename = "enum", skip_serializing_if = "Option::is_none")]
99 pub enum_values: Option<Vec<String>>,
100
101 #[serde(skip_serializing_if = "Option::is_none")]
102 pub default: Option<Value>,
103
104 #[serde(skip_serializing_if = "Option::is_none")]
106 pub minimum: Option<f64>,
107
108 #[serde(skip_serializing_if = "Option::is_none")]
110 pub maximum: Option<f64>,
111
112 #[serde(skip_serializing_if = "Option::is_none")]
114 pub items: Option<Box<PropertySchema>>,
115
116 #[serde(rename = "x-enriched", skip_serializing_if = "Option::is_none")]
118 pub enriched: Option<bool>,
119}
120
121impl PropertySchema {
122 pub fn string(description: &str) -> Self {
124 Self {
125 schema_type: "string".into(),
126 description: Some(description.into()),
127 ..Default::default()
128 }
129 }
130
131 pub fn string_enum(values: &[&str], description: &str) -> Self {
133 Self {
134 schema_type: "string".into(),
135 description: Some(description.into()),
136 enum_values: Some(values.iter().map(|s| s.to_string()).collect()),
137 enriched: Some(true),
138 ..Default::default()
139 }
140 }
141
142 pub fn number(description: &str) -> Self {
144 Self {
145 schema_type: "number".into(),
146 description: Some(description.into()),
147 ..Default::default()
148 }
149 }
150
151 pub fn integer(description: &str, min: Option<f64>, max: Option<f64>) -> Self {
153 Self {
154 schema_type: "integer".into(),
155 description: Some(description.into()),
156 minimum: min,
157 maximum: max,
158 ..Default::default()
159 }
160 }
161
162 pub fn boolean(description: &str) -> Self {
164 Self {
165 schema_type: "boolean".into(),
166 description: Some(description.into()),
167 ..Default::default()
168 }
169 }
170
171 pub fn array(items: PropertySchema, description: &str) -> Self {
173 Self {
174 schema_type: "array".into(),
175 description: Some(description.into()),
176 items: Some(Box::new(items)),
177 ..Default::default()
178 }
179 }
180}
181
182impl Default for PropertySchema {
183 fn default() -> Self {
184 Self {
185 schema_type: "string".into(),
186 description: None,
187 enum_values: None,
188 default: None,
189 minimum: None,
190 maximum: None,
191 items: None,
192 enriched: None,
193 }
194 }
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize)]
202pub struct ToolSchema {
203 pub properties: HashMap<String, PropertySchema>,
205 #[serde(default, skip_serializing_if = "Vec::is_empty")]
207 pub required: Vec<String>,
208}
209
210impl ToolSchema {
211 pub fn new() -> Self {
213 Self {
214 properties: HashMap::new(),
215 required: Vec::new(),
216 }
217 }
218
219 pub fn from_json(schema: &Value) -> Self {
221 serde_json::from_value::<ToolSchema>(schema.clone()).unwrap_or_else(|_| {
222 let properties = schema
224 .get("properties")
225 .and_then(|p| {
226 serde_json::from_value::<HashMap<String, PropertySchema>>(p.clone()).ok()
227 })
228 .unwrap_or_default();
229 let required = schema
230 .get("required")
231 .and_then(|r| r.as_array())
232 .map(|arr| {
233 arr.iter()
234 .filter_map(|v| v.as_str().map(String::from))
235 .collect()
236 })
237 .unwrap_or_default();
238 Self {
239 properties,
240 required,
241 }
242 })
243 }
244
245 pub fn to_json(&self) -> Value {
247 let mut schema = serde_json::json!({
248 "type": "object",
249 "properties": self.properties,
250 });
251 if !self.required.is_empty() {
252 schema["required"] = serde_json::json!(self.required);
253 }
254 schema
255 }
256
257 pub fn add_enum_param(&mut self, name: &str, values: &[&str], description: &str) {
259 self.properties.insert(
260 name.into(),
261 PropertySchema::string_enum(values, description),
262 );
263 }
264
265 pub fn set_enum(&mut self, param: &str, values: &[String]) {
267 if let Some(prop) = self.properties.get_mut(param) {
268 prop.enum_values = Some(values.to_vec());
269 prop.enriched = Some(true);
270 }
271 }
272
273 pub fn add_property(&mut self, name: &str, prop: PropertySchema) {
275 self.properties.insert(name.into(), prop);
276 }
277
278 pub fn add_param(&mut self, name: &str, schema: Value) {
280 if let Ok(prop) = serde_json::from_value::<PropertySchema>(schema) {
281 self.properties.insert(name.into(), prop);
282 }
283 }
284
285 pub fn remove_params(&mut self, names: &[&str]) {
287 for name in names {
288 self.properties.remove(*name);
289 self.required.retain(|r| r != *name);
290 }
291 }
292
293 pub fn set_required(&mut self, param: &str, required: bool) {
295 if required {
296 if !self.required.contains(¶m.to_string()) {
297 self.required.push(param.into());
298 }
299 } else {
300 self.required.retain(|r| r != param);
301 }
302 }
303
304 pub fn set_description(&mut self, param: &str, desc: &str) {
306 if let Some(prop) = self.properties.get_mut(param) {
307 prop.description = Some(desc.into());
308 }
309 }
310
311 pub fn set_default(&mut self, param: &str, value: Value) {
313 if let Some(prop) = self.properties.get_mut(param) {
314 prop.default = Some(value);
315 }
316 }
317}
318
319impl Default for ToolSchema {
320 fn default() -> Self {
321 Self::new()
322 }
323}
324
325pub fn sanitize_field_name(name: &str) -> String {
331 let sanitized: String = name
332 .chars()
333 .map(|c| {
334 if c.is_ascii_alphanumeric() {
335 c.to_ascii_lowercase()
336 } else {
337 '_'
338 }
339 })
340 .collect();
341 let collapsed = sanitized
342 .split('_')
343 .filter(|s| !s.is_empty())
344 .collect::<Vec<_>>()
345 .join("_");
346 format!("cf_{collapsed}")
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352
353 #[test]
354 fn test_sanitize_field_name() {
355 assert_eq!(sanitize_field_name("Story Points"), "cf_story_points");
356 assert_eq!(sanitize_field_name("Risk Level"), "cf_risk_level");
357 assert_eq!(
358 sanitize_field_name("My Custom Field!"),
359 "cf_my_custom_field"
360 );
361 assert_eq!(sanitize_field_name("simple"), "cf_simple");
362 assert_eq!(sanitize_field_name("Приоритет"), "cf_");
364 }
365
366 #[test]
367 fn test_property_schema_constructors() {
368 let s = PropertySchema::string("A description");
369 assert_eq!(s.schema_type, "string");
370 assert_eq!(s.description.as_deref(), Some("A description"));
371
372 let e = PropertySchema::string_enum(&["a", "b"], "Pick one");
373 assert_eq!(e.enum_values, Some(vec!["a".to_string(), "b".to_string()]));
374 assert_eq!(e.enriched, Some(true));
375
376 let n = PropertySchema::number("Count");
377 assert_eq!(n.schema_type, "number");
378
379 let i = PropertySchema::integer("Limit", Some(1.0), Some(100.0));
380 assert_eq!(i.minimum, Some(1.0));
381 assert_eq!(i.maximum, Some(100.0));
382
383 let b = PropertySchema::boolean("Flag");
384 assert_eq!(b.schema_type, "boolean");
385
386 let a = PropertySchema::array(PropertySchema::string("item"), "List");
387 assert_eq!(a.schema_type, "array");
388 assert!(a.items.is_some());
389 }
390
391 #[test]
392 fn test_tool_schema_add_enum_param() {
393 let mut schema = ToolSchema::new();
394 schema.add_enum_param("status", &["open", "closed"], "Issue status");
395 let prop = schema.properties.get("status").unwrap();
396 assert_eq!(prop.schema_type, "string");
397 assert_eq!(
398 prop.enum_values,
399 Some(vec!["open".to_string(), "closed".to_string()])
400 );
401 assert_eq!(prop.enriched, Some(true));
402 }
403
404 #[test]
405 fn test_tool_schema_remove_params() {
406 let mut schema = ToolSchema::from_json(&serde_json::json!({
407 "type": "object",
408 "properties": {
409 "title": { "type": "string" },
410 "priority": { "type": "string" },
411 },
412 "required": ["title", "priority"],
413 }));
414 schema.remove_params(&["priority"]);
415 assert!(!schema.properties.contains_key("priority"));
416 assert_eq!(schema.required, vec!["title"]);
417 }
418
419 #[test]
420 fn test_tool_schema_roundtrip() {
421 let mut schema = ToolSchema::new();
422 schema.add_property("title", PropertySchema::string("Title"));
423 schema.set_required("title", true);
424
425 let json = schema.to_json();
426 assert_eq!(json["properties"]["title"]["type"], "string");
427 assert_eq!(json["required"], serde_json::json!(["title"]));
428
429 let restored = ToolSchema::from_json(&json);
430 assert!(restored.properties.contains_key("title"));
431 assert_eq!(restored.required, vec!["title"]);
432 }
433
434 #[test]
435 fn test_tool_schema_set_enum() {
436 let mut schema = ToolSchema::new();
437 schema.add_property("state", PropertySchema::string("Filter by state"));
438 schema.set_enum(
439 "state",
440 &["opened".into(), "closed".into(), "merged".into()],
441 );
442 let state = schema.properties.get("state").unwrap();
443 assert_eq!(
444 state.enum_values,
445 Some(vec![
446 "opened".to_string(),
447 "closed".to_string(),
448 "merged".to_string()
449 ])
450 );
451 assert_eq!(state.enriched, Some(true));
452 assert_eq!(state.description.as_deref(), Some("Filter by state"));
454 }
455
456 #[test]
457 fn test_tool_schema_set_required() {
458 let mut schema = ToolSchema::new();
459 schema.required = vec!["title".into()];
460
461 schema.set_required("description", true);
462 assert_eq!(schema.required, vec!["title", "description"]);
463
464 schema.set_required("title", false);
465 assert_eq!(schema.required, vec!["description"]);
466
467 schema.set_required("description", true);
469 assert_eq!(schema.required, vec!["description"]);
470 }
471
472 #[test]
473 fn test_tool_schema_set_default() {
474 let mut schema = ToolSchema::new();
475 schema.add_property("limit", PropertySchema::integer("Max results", None, None));
476 schema.set_default("limit", serde_json::json!(20));
477 assert_eq!(
478 schema.properties.get("limit").unwrap().default,
479 Some(serde_json::json!(20))
480 );
481 }
482
483 #[test]
484 fn test_tool_schema_add_param_from_json() {
485 let mut schema = ToolSchema::new();
486 schema.add_param(
487 "cf_risk",
488 serde_json::json!({
489 "type": "string",
490 "enum": ["Low", "Medium", "High"],
491 "description": "Risk level",
492 "x-enriched": true,
493 }),
494 );
495 let prop = schema.properties.get("cf_risk").unwrap();
496 assert_eq!(prop.schema_type, "string");
497 assert_eq!(
498 prop.enum_values,
499 Some(vec![
500 "Low".to_string(),
501 "Medium".to_string(),
502 "High".to_string()
503 ])
504 );
505 }
506
507 #[test]
508 fn test_from_json_backward_compat() {
509 let json = serde_json::json!({
510 "type": "object",
511 "properties": {
512 "state": {
513 "type": "string",
514 "enum": ["open", "closed"],
515 "description": "Issue state"
516 },
517 "limit": {
518 "type": "integer",
519 "minimum": 1,
520 "maximum": 100
521 }
522 },
523 "required": ["state"]
524 });
525
526 let schema = ToolSchema::from_json(&json);
527 assert_eq!(schema.properties.len(), 2);
528 assert_eq!(schema.required, vec!["state"]);
529
530 let state = schema.properties.get("state").unwrap();
531 assert_eq!(state.schema_type, "string");
532 assert_eq!(
533 state.enum_values,
534 Some(vec!["open".to_string(), "closed".to_string()])
535 );
536
537 let limit = schema.properties.get("limit").unwrap();
538 assert_eq!(limit.schema_type, "integer");
539 assert_eq!(limit.minimum, Some(1.0));
540 assert_eq!(limit.maximum, Some(100.0));
541 }
542}