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", default, skip_serializing_if = "String::is_empty")]
95 pub schema_type: String,
96
97 #[serde(skip_serializing_if = "Option::is_none")]
99 pub description: Option<String>,
100
101 #[serde(rename = "enum", skip_serializing_if = "Option::is_none")]
103 pub enum_values: Option<Vec<String>>,
104
105 #[serde(skip_serializing_if = "Option::is_none")]
106 pub default: Option<Value>,
107
108 #[serde(skip_serializing_if = "Option::is_none")]
110 pub minimum: Option<f64>,
111
112 #[serde(skip_serializing_if = "Option::is_none")]
114 pub maximum: Option<f64>,
115
116 #[serde(skip_serializing_if = "Option::is_none")]
118 pub items: Option<Box<PropertySchema>>,
119
120 #[serde(rename = "anyOf", default, skip_serializing_if = "Option::is_none")]
127 pub any_of: Option<Vec<PropertySchema>>,
128
129 #[serde(rename = "x-enriched", skip_serializing_if = "Option::is_none")]
131 pub enriched: Option<bool>,
132}
133
134impl PropertySchema {
135 pub fn string(description: &str) -> Self {
137 Self {
138 schema_type: "string".into(),
139 description: Some(description.into()),
140 ..Default::default()
141 }
142 }
143
144 pub fn string_enum(values: &[&str], description: &str) -> Self {
146 Self {
147 schema_type: "string".into(),
148 description: Some(description.into()),
149 enum_values: Some(values.iter().map(|s| s.to_string()).collect()),
150 enriched: Some(true),
151 ..Default::default()
152 }
153 }
154
155 pub fn number(description: &str) -> Self {
157 Self {
158 schema_type: "number".into(),
159 description: Some(description.into()),
160 ..Default::default()
161 }
162 }
163
164 pub fn integer(description: &str, min: Option<f64>, max: Option<f64>) -> Self {
166 Self {
167 schema_type: "integer".into(),
168 description: Some(description.into()),
169 minimum: min,
170 maximum: max,
171 ..Default::default()
172 }
173 }
174
175 pub fn boolean(description: &str) -> Self {
177 Self {
178 schema_type: "boolean".into(),
179 description: Some(description.into()),
180 ..Default::default()
181 }
182 }
183
184 pub fn object(description: &str) -> Self {
186 Self {
187 schema_type: "object".into(),
188 description: Some(description.into()),
189 ..Default::default()
190 }
191 }
192
193 pub fn array(items: PropertySchema, description: &str) -> Self {
195 Self {
196 schema_type: "array".into(),
197 description: Some(description.into()),
198 items: Some(Box::new(items)),
199 ..Default::default()
200 }
201 }
202
203 pub fn any_of(description: &str, schemas: Vec<PropertySchema>) -> Self {
211 Self {
212 schema_type: String::new(),
213 description: Some(description.into()),
214 any_of: Some(schemas),
215 enriched: Some(true),
216 ..Default::default()
217 }
218 }
219}
220
221impl Default for PropertySchema {
222 fn default() -> Self {
223 Self {
224 schema_type: "string".into(),
225 description: None,
226 enum_values: None,
227 default: None,
228 minimum: None,
229 maximum: None,
230 items: None,
231 any_of: None,
232 enriched: None,
233 }
234 }
235}
236
237#[derive(Debug, Clone, Serialize, Deserialize)]
242pub struct ToolSchema {
243 pub properties: HashMap<String, PropertySchema>,
245 #[serde(default, skip_serializing_if = "Vec::is_empty")]
247 pub required: Vec<String>,
248}
249
250impl ToolSchema {
251 pub fn new() -> Self {
253 Self {
254 properties: HashMap::new(),
255 required: Vec::new(),
256 }
257 }
258
259 pub fn from_json(schema: &Value) -> Self {
261 serde_json::from_value::<ToolSchema>(schema.clone()).unwrap_or_else(|_| {
262 let properties = schema
264 .get("properties")
265 .and_then(|p| {
266 serde_json::from_value::<HashMap<String, PropertySchema>>(p.clone()).ok()
267 })
268 .unwrap_or_default();
269 let required = schema
270 .get("required")
271 .and_then(|r| r.as_array())
272 .map(|arr| {
273 arr.iter()
274 .filter_map(|v| v.as_str().map(String::from))
275 .collect()
276 })
277 .unwrap_or_default();
278 Self {
279 properties,
280 required,
281 }
282 })
283 }
284
285 pub fn to_json(&self) -> Value {
287 let mut schema = serde_json::json!({
288 "type": "object",
289 "properties": self.properties,
290 });
291 if !self.required.is_empty() {
292 schema["required"] = serde_json::json!(self.required);
293 }
294 schema
295 }
296
297 pub fn add_enum_param(&mut self, name: &str, values: &[&str], description: &str) {
299 self.properties.insert(
300 name.into(),
301 PropertySchema::string_enum(values, description),
302 );
303 }
304
305 pub fn set_enum(&mut self, param: &str, values: &[String]) {
307 if let Some(prop) = self.properties.get_mut(param) {
308 prop.enum_values = Some(values.to_vec());
309 prop.enriched = Some(true);
310 }
311 }
312
313 pub fn add_property(&mut self, name: &str, prop: PropertySchema) {
315 self.properties.insert(name.into(), prop);
316 }
317
318 pub fn add_param(&mut self, name: &str, schema: Value) {
320 if let Ok(prop) = serde_json::from_value::<PropertySchema>(schema) {
321 self.properties.insert(name.into(), prop);
322 }
323 }
324
325 pub fn remove_params(&mut self, names: &[&str]) {
327 for name in names {
328 self.properties.remove(*name);
329 self.required.retain(|r| r != *name);
330 }
331 }
332
333 pub fn set_required(&mut self, param: &str, required: bool) {
335 if required {
336 if !self.required.contains(¶m.to_string()) {
337 self.required.push(param.into());
338 }
339 } else {
340 self.required.retain(|r| r != param);
341 }
342 }
343
344 pub fn set_description(&mut self, param: &str, desc: &str) {
346 if let Some(prop) = self.properties.get_mut(param) {
347 prop.description = Some(desc.into());
348 }
349 }
350
351 pub fn set_default(&mut self, param: &str, value: Value) {
353 if let Some(prop) = self.properties.get_mut(param) {
354 prop.default = Some(value);
355 }
356 }
357}
358
359impl Default for ToolSchema {
360 fn default() -> Self {
361 Self::new()
362 }
363}
364
365pub fn sanitize_field_name(name: &str) -> String {
371 let sanitized: String = name
372 .chars()
373 .map(|c| {
374 if c.is_ascii_alphanumeric() {
375 c.to_ascii_lowercase()
376 } else {
377 '_'
378 }
379 })
380 .collect();
381 let collapsed = sanitized
382 .split('_')
383 .filter(|s| !s.is_empty())
384 .collect::<Vec<_>>()
385 .join("_");
386 format!("cf_{collapsed}")
387}
388
389#[cfg(test)]
390mod tests {
391 use super::*;
392
393 #[test]
394 fn test_sanitize_field_name() {
395 assert_eq!(sanitize_field_name("Story Points"), "cf_story_points");
396 assert_eq!(sanitize_field_name("Risk Level"), "cf_risk_level");
397 assert_eq!(
398 sanitize_field_name("My Custom Field!"),
399 "cf_my_custom_field"
400 );
401 assert_eq!(sanitize_field_name("simple"), "cf_simple");
402 assert_eq!(sanitize_field_name("Приоритет"), "cf_");
404 }
405
406 #[test]
407 fn test_property_schema_constructors() {
408 let s = PropertySchema::string("A description");
409 assert_eq!(s.schema_type, "string");
410 assert_eq!(s.description.as_deref(), Some("A description"));
411
412 let e = PropertySchema::string_enum(&["a", "b"], "Pick one");
413 assert_eq!(e.enum_values, Some(vec!["a".to_string(), "b".to_string()]));
414 assert_eq!(e.enriched, Some(true));
415
416 let n = PropertySchema::number("Count");
417 assert_eq!(n.schema_type, "number");
418
419 let i = PropertySchema::integer("Limit", Some(1.0), Some(100.0));
420 assert_eq!(i.minimum, Some(1.0));
421 assert_eq!(i.maximum, Some(100.0));
422
423 let b = PropertySchema::boolean("Flag");
424 assert_eq!(b.schema_type, "boolean");
425
426 let o = PropertySchema::object("Values by key");
427 assert_eq!(o.schema_type, "object");
428
429 let a = PropertySchema::array(PropertySchema::string("item"), "List");
430 assert_eq!(a.schema_type, "array");
431 assert!(a.items.is_some());
432 }
433
434 #[test]
438 fn test_property_schema_any_of_constructor() {
439 let alt = PropertySchema::any_of(
440 "Severity (varies per project)",
441 vec![
442 PropertySchema::string_enum(&["High", "Medium", "Low"], "Project A"),
443 PropertySchema::string_enum(&["P1", "P2", "P3"], "Project B"),
444 ],
445 );
446 assert_eq!(alt.schema_type, "");
447 assert_eq!(
448 alt.description.as_deref(),
449 Some("Severity (varies per project)")
450 );
451 assert_eq!(alt.enriched, Some(true));
452 let variants = alt.any_of.as_ref().expect("anyOf set");
453 assert_eq!(variants.len(), 2);
454 assert_eq!(variants[0].enum_values.as_ref().unwrap()[0], "High");
455 assert_eq!(variants[1].enum_values.as_ref().unwrap()[0], "P1");
456 }
457
458 #[test]
464 fn test_property_schema_any_of_serialization_omits_empty_type() {
465 let alt = PropertySchema::any_of(
466 "alt",
467 vec![PropertySchema::string("a"), PropertySchema::number("b")],
468 );
469 let value = serde_json::to_value(&alt).unwrap();
470 let obj = value.as_object().expect("object");
471 assert!(
472 !obj.contains_key("type"),
473 "outer object must not have type: {value}"
474 );
475 assert!(obj.contains_key("anyOf"), "missing anyOf: {value}");
476 let any_of = obj["anyOf"].as_array().unwrap();
478 assert_eq!(any_of[0]["type"], "string");
479 assert_eq!(any_of[1]["type"], "number");
480 }
481
482 #[test]
483 fn test_tool_schema_add_enum_param() {
484 let mut schema = ToolSchema::new();
485 schema.add_enum_param("status", &["open", "closed"], "Issue status");
486 let prop = schema.properties.get("status").unwrap();
487 assert_eq!(prop.schema_type, "string");
488 assert_eq!(
489 prop.enum_values,
490 Some(vec!["open".to_string(), "closed".to_string()])
491 );
492 assert_eq!(prop.enriched, Some(true));
493 }
494
495 #[test]
496 fn test_tool_schema_remove_params() {
497 let mut schema = ToolSchema::from_json(&serde_json::json!({
498 "type": "object",
499 "properties": {
500 "title": { "type": "string" },
501 "priority": { "type": "string" },
502 },
503 "required": ["title", "priority"],
504 }));
505 schema.remove_params(&["priority"]);
506 assert!(!schema.properties.contains_key("priority"));
507 assert_eq!(schema.required, vec!["title"]);
508 }
509
510 #[test]
511 fn test_tool_schema_roundtrip() {
512 let mut schema = ToolSchema::new();
513 schema.add_property("title", PropertySchema::string("Title"));
514 schema.set_required("title", true);
515
516 let json = schema.to_json();
517 assert_eq!(json["properties"]["title"]["type"], "string");
518 assert_eq!(json["required"], serde_json::json!(["title"]));
519
520 let restored = ToolSchema::from_json(&json);
521 assert!(restored.properties.contains_key("title"));
522 assert_eq!(restored.required, vec!["title"]);
523 }
524
525 #[test]
526 fn test_tool_schema_set_enum() {
527 let mut schema = ToolSchema::new();
528 schema.add_property("state", PropertySchema::string("Filter by state"));
529 schema.set_enum(
530 "state",
531 &["opened".into(), "closed".into(), "merged".into()],
532 );
533 let state = schema.properties.get("state").unwrap();
534 assert_eq!(
535 state.enum_values,
536 Some(vec![
537 "opened".to_string(),
538 "closed".to_string(),
539 "merged".to_string()
540 ])
541 );
542 assert_eq!(state.enriched, Some(true));
543 assert_eq!(state.description.as_deref(), Some("Filter by state"));
545 }
546
547 #[test]
548 fn test_tool_schema_set_required() {
549 let mut schema = ToolSchema::new();
550 schema.required = vec!["title".into()];
551
552 schema.set_required("description", true);
553 assert_eq!(schema.required, vec!["title", "description"]);
554
555 schema.set_required("title", false);
556 assert_eq!(schema.required, vec!["description"]);
557
558 schema.set_required("description", true);
560 assert_eq!(schema.required, vec!["description"]);
561 }
562
563 #[test]
564 fn test_tool_schema_set_default() {
565 let mut schema = ToolSchema::new();
566 schema.add_property("limit", PropertySchema::integer("Max results", None, None));
567 schema.set_default("limit", serde_json::json!(20));
568 assert_eq!(
569 schema.properties.get("limit").unwrap().default,
570 Some(serde_json::json!(20))
571 );
572 }
573
574 #[test]
575 fn test_tool_schema_add_param_from_json() {
576 let mut schema = ToolSchema::new();
577 schema.add_param(
578 "cf_risk",
579 serde_json::json!({
580 "type": "string",
581 "enum": ["Low", "Medium", "High"],
582 "description": "Risk level",
583 "x-enriched": true,
584 }),
585 );
586 let prop = schema.properties.get("cf_risk").unwrap();
587 assert_eq!(prop.schema_type, "string");
588 assert_eq!(
589 prop.enum_values,
590 Some(vec![
591 "Low".to_string(),
592 "Medium".to_string(),
593 "High".to_string()
594 ])
595 );
596 }
597
598 #[test]
599 fn test_from_json_backward_compat() {
600 let json = serde_json::json!({
601 "type": "object",
602 "properties": {
603 "state": {
604 "type": "string",
605 "enum": ["open", "closed"],
606 "description": "Issue state"
607 },
608 "limit": {
609 "type": "integer",
610 "minimum": 1,
611 "maximum": 100
612 }
613 },
614 "required": ["state"]
615 });
616
617 let schema = ToolSchema::from_json(&json);
618 assert_eq!(schema.properties.len(), 2);
619 assert_eq!(schema.required, vec!["state"]);
620
621 let state = schema.properties.get("state").unwrap();
622 assert_eq!(state.schema_type, "string");
623 assert_eq!(
624 state.enum_values,
625 Some(vec!["open".to_string(), "closed".to_string()])
626 );
627
628 let limit = schema.properties.get("limit").unwrap();
629 assert_eq!(limit.schema_type, "integer");
630 assert_eq!(limit.minimum, Some(1.0));
631 assert_eq!(limit.maximum, Some(100.0));
632 }
633}