1use crate::paths::{self, decomposed_prefix, json_ext, md_ext};
2use serde_json::{Map, Value, json};
3use std::collections::{HashMap, HashSet};
4use std::hash::BuildHasher;
5
6const FULL_METADATA_REL: &str = "schemas/full/metadata.json";
7const DECOMPOSED_METADATA_REL: &str = "schemas/decomposed/metadata.json";
8
9#[derive(Debug, Clone)]
10pub struct CatalogIndex {
11 pub tools: Vec<Value>,
12 pub files: HashMap<String, String>,
13}
14
15#[must_use]
17pub fn catalog_index_from_value(val: &Value) -> CatalogIndex {
18 let tools = val
19 .get("tools")
20 .and_then(|v| v.as_array())
21 .cloned()
22 .unwrap_or_default();
23 let mut files = HashMap::new();
24 if let Some(map) = val.get("files").and_then(|v| v.as_object()) {
25 for (k, v) in map {
26 if let Some(s) = v.as_str() {
27 files.insert(k.clone(), s.to_string());
28 }
29 }
30 }
31 CatalogIndex { tools, files }
32}
33
34fn json_insert_token_count_placeholder(entry: &mut Value) {
35 if let Some(obj) = entry.as_object_mut() {
36 obj.insert("token_count".into(), Value::Null);
37 }
38}
39
40impl CatalogIndex {
41 #[must_use]
42 pub fn to_catalog_dict(&self) -> Value {
43 self.to_catalog_dict_with_prefix(&crate::paths::catalog_prefix())
44 }
45
46 #[must_use]
48 pub fn tool_schema_metadata(&self) -> Value {
49 tool_schema_metadata_from_files(&self.files)
50 }
51
52 #[must_use]
53 pub fn to_catalog_dict_with_prefix(&self, catalog_prefix: &str) -> Value {
54 let mut md_entries = Vec::new();
55 let mut json_entries = Vec::new();
56 let mut paths: Vec<_> = self.files.keys().cloned().collect();
57 paths.sort();
58
59 for rel_path in paths {
60 if !rel_path.starts_with(&decomposed_prefix()) {
61 continue;
62 }
63 let content = &self.files[&rel_path];
64 let file_path = format!("{catalog_prefix}/{rel_path}");
65 if rel_path.ends_with(&md_ext()) {
66 let id = path_stem(&rel_path);
67 let mut entry = json!({
68 "id": id,
69 "file_path": file_path,
70 "score": 1.0,
71 "start_line": 1,
72 "end_line": 1,
73 "language": "markdown",
74 "content": content,
75 });
76 json_insert_token_count_placeholder(&mut entry);
77 md_entries.push(entry);
78 } else if rel_path.ends_with(&json_ext()) {
79 let Ok(parsed) = serde_json::from_str::<Value>(content) else {
80 continue;
81 };
82 if !parsed.is_object() {
83 continue;
84 }
85 let line_count = content.lines().count();
86 let entry_id = parsed.get("id").cloned().unwrap_or_else(|| {
87 Value::String(paths::tool_id_from_decomposed_rel(&rel_path))
88 });
89 let mut entry = json!({
90 "id": entry_id,
91 "name": entry_id,
92 "file_path": file_path,
93 "score": 1.0,
94 "start_line": 1,
95 "end_line": line_count,
96 "language": "json",
97 "content": parsed,
98 });
99 json_insert_token_count_placeholder(&mut entry);
100 json_entries.push(entry);
101 }
102 }
103 json!({
104 "md": md_entries,
105 "json": json_entries,
106 "tools": self.tools,
107 })
108 }
109}
110
111fn path_stem(path: &str) -> String {
112 std::path::Path::new(path)
113 .file_stem()
114 .unwrap_or_default()
115 .to_string_lossy()
116 .into_owned()
117}
118
119#[must_use]
120pub fn catalog_tool_count(data: &Value) -> usize {
121 if let Some(tools) = data.get("tools").and_then(|v| v.as_array())
122 && !tools.is_empty()
123 {
124 return tools.len();
125 }
126 let Some(json_items) = data.get("json").and_then(|v| v.as_array()) else {
127 return 0;
128 };
129 let mut tool_ids = HashSet::new();
130 for item in json_items {
131 let Some(obj) = item.as_object() else {
132 continue;
133 };
134 if let Some(fp) = obj.get("file_path").and_then(|v| v.as_str())
135 && !fp.is_empty()
136 {
137 tool_ids.insert(paths::tool_id_from_decomposed_rel(fp));
138 continue;
139 }
140 let id = obj
141 .get("id")
142 .or_else(|| obj.get("name"))
143 .and_then(|v| v.as_str())
144 .unwrap_or("");
145 if !id.is_empty() {
146 tool_ids.insert(id.to_string());
147 }
148 }
149 tool_ids.len()
150}
151
152fn enum_markdown_value(val: &Value) -> String {
154 match val {
155 Value::String(s) => s.clone(),
156 Value::Number(n) => n.to_string(),
157 Value::Bool(b) => b.to_string(),
158 other => other.to_string(),
159 }
160}
161
162#[must_use]
163pub fn dedupe_enums(all_enums: &[Value]) -> Vec<Value> {
164 let mut seen = HashSet::new();
165 let mut unique = Vec::new();
166 for val in all_enums {
167 let key = serde_json::to_string(val).unwrap_or_default();
168 if seen.insert(key) {
169 unique.push(val.clone());
170 }
171 }
172 unique.sort_by(|a, b| {
173 serde_json::to_string(a)
174 .unwrap_or_default()
175 .cmp(&serde_json::to_string(b).unwrap_or_default())
176 });
177 unique
178}
179
180type PathSegment = Map<String, Value>;
181type Extraction = (Vec<PathSegment>, Value);
182
183fn segment(seg_type: &str, extra: Map<String, Value>) -> PathSegment {
184 let mut m = Map::new();
185 m.insert("type".into(), Value::String(seg_type.into()));
186 for (k, v) in extra {
187 m.insert(k, v);
188 }
189 m
190}
191
192fn build_property_file(tool_name: &str, path: &[PathSegment], leaf_schema: Value) -> Value {
193 let mut current = leaf_schema;
194 for segment in path.iter().rev() {
195 let seg_type = segment.get("type").and_then(|v| v.as_str()).unwrap_or("");
196 current = match seg_type {
197 "properties" => {
198 let name = segment.get("name").and_then(|v| v.as_str()).unwrap_or("");
199 let mut props = Map::new();
200 props.insert(name.into(), current);
201 json!({"properties": Value::Object(props)})
202 }
203 "items" => {
204 if segment.contains_key("index") {
205 json!({"items": vec![current]})
206 } else {
207 json!({"items": current})
208 }
209 }
210 "allOf" | "anyOf" | "oneOf" => {
211 let mut m = Map::new();
212 m.insert(seg_type.into(), Value::Array(vec![current]));
213 Value::Object(m)
214 }
215 "additionalProperties" => json!({"additionalProperties": current}),
216 "patternProperties" => {
217 let pat = segment
218 .get("pattern")
219 .and_then(|v| v.as_str())
220 .unwrap_or("");
221 let mut pp = Map::new();
222 pp.insert(pat.into(), current);
223 json!({"patternProperties": Value::Object(pp)})
224 }
225 "if" | "then" | "else" | "not" | "contains" | "propertyNames" => {
226 let mut m = Map::new();
227 m.insert(seg_type.into(), current);
228 Value::Object(m)
229 }
230 _ => current,
231 };
232 }
233 json!({
234 "id": tool_name,
235 "name": tool_name,
236 "inputSchema": current,
237 })
238}
239
240fn process_node(
241 node: &Value,
242 tool_name: &str,
243 server_name: &str,
244 path: &[PathSegment],
245 extractions: &mut Vec<Extraction>,
246) -> Value {
247 let Some(obj) = node.as_object() else {
248 return node.clone();
249 };
250 let mut result: Map<String, Value> = obj.clone();
251 process_compositions(&mut result, tool_name, server_name, path, extractions);
252
253 if let Some(props) = result
254 .get("properties")
255 .and_then(|v| v.as_object())
256 .cloned()
257 {
258 let req_props: HashSet<String> = result
259 .get("required")
260 .and_then(|v| v.as_array())
261 .map(|arr| {
262 arr.iter()
263 .filter_map(|v| v.as_str().map(String::from))
264 .collect()
265 })
266 .unwrap_or_default();
267
268 let mut filtered = Map::new();
269 for (prop_name, prop_schema) in props {
270 let mut child_path = path.to_vec();
271 let mut name_seg = Map::new();
272 name_seg.insert("type".into(), Value::String("properties".into()));
273 name_seg.insert("name".into(), Value::String(prop_name.clone()));
274 child_path.push(name_seg);
275
276 if req_props.contains(&prop_name) {
277 filtered.insert(
278 prop_name.clone(),
279 process_node(
280 &prop_schema,
281 tool_name,
282 server_name,
283 &child_path,
284 extractions,
285 ),
286 );
287 } else {
288 let filtered_child = process_node(
289 &prop_schema,
290 tool_name,
291 server_name,
292 &child_path,
293 extractions,
294 );
295 let prop_file = build_property_file(tool_name, &child_path, filtered_child);
296 extractions.push((child_path, prop_file));
297 }
298 }
299 result.insert("properties".into(), Value::Object(filtered));
300 }
301 Value::Object(result)
302}
303
304fn process_compositions(
305 result: &mut Map<String, Value>,
306 tool_name: &str,
307 server_name: &str,
308 path: &[PathSegment],
309 extractions: &mut Vec<Extraction>,
310) {
311 handle_logical_compositions(result, tool_name, server_name, path, extractions);
312 handle_conditional_compositions(result, tool_name, server_name, path, extractions);
313 handle_array_properties(result, tool_name, server_name, path, extractions);
314 handle_miscellaneous_keywords(result, tool_name, server_name, path, extractions);
315}
316
317fn handle_logical_compositions(
318 result: &mut Map<String, Value>,
319 tool_name: &str,
320 server_name: &str,
321 path: &[PathSegment],
322 extractions: &mut Vec<Extraction>,
323) {
324 for key in ["allOf", "anyOf", "oneOf"] {
325 if let Some(items) = result.get(key).and_then(|v| v.as_array()).cloned() {
326 let processed: Vec<Value> = items
327 .into_iter()
328 .enumerate()
329 .map(|(i, item)| {
330 let mut p = path.to_vec();
331 let mut seg = Map::new();
332 seg.insert("type".into(), Value::String(key.into()));
333 seg.insert("index".into(), Value::Number(i.into()));
334 p.push(seg);
335 process_node(&item, tool_name, server_name, &p, extractions)
336 })
337 .collect();
338 result.insert(key.into(), Value::Array(processed));
339 }
340 }
341}
342
343fn handle_conditional_compositions(
344 result: &mut Map<String, Value>,
345 tool_name: &str,
346 server_name: &str,
347 path: &[PathSegment],
348 extractions: &mut Vec<Extraction>,
349) {
350 for key in ["if", "then", "else"] {
351 if result.contains_key(key) {
352 let val = result.get(key).cloned().unwrap_or(Value::Null);
353 let mut p = path.to_vec();
354 p.push(segment(key, Map::new()));
355 result.insert(
356 key.into(),
357 process_node(&val, tool_name, server_name, &p, extractions),
358 );
359 }
360 }
361 if result.contains_key("not") {
362 let val = result.get("not").cloned().unwrap_or(Value::Null);
363 let mut p = path.to_vec();
364 p.push(segment("not", Map::new()));
365 result.insert(
366 "not".into(),
367 process_node(&val, tool_name, server_name, &p, extractions),
368 );
369 }
370}
371
372fn handle_array_properties(
373 result: &mut Map<String, Value>,
374 tool_name: &str,
375 server_name: &str,
376 path: &[PathSegment],
377 extractions: &mut Vec<Extraction>,
378) {
379 if let Some(items) = result.get("items").cloned() {
380 let processed = if let Some(obj) = items.as_object() {
381 let mut p = path.to_vec();
382 p.push(segment("items", Map::new()));
383 process_node(
384 &Value::Object(obj.clone()),
385 tool_name,
386 server_name,
387 &p,
388 extractions,
389 )
390 } else if let Some(arr) = items.as_array() {
391 Value::Array(
392 arr.iter()
393 .enumerate()
394 .map(|(i, item)| {
395 let mut p = path.to_vec();
396 let mut seg = Map::new();
397 seg.insert("type".into(), Value::String("items".into()));
398 seg.insert("index".into(), Value::Number(i.into()));
399 p.push(seg);
400 process_node(item, tool_name, server_name, &p, extractions)
401 })
402 .collect(),
403 )
404 } else {
405 items
406 };
407 result.insert("items".into(), processed);
408 }
409}
410
411fn handle_miscellaneous_keywords(
412 result: &mut Map<String, Value>,
413 tool_name: &str,
414 server_name: &str,
415 path: &[PathSegment],
416 extractions: &mut Vec<Extraction>,
417) {
418 for key in ["contains", "propertyNames", "additionalProperties"] {
419 if let Some(obj) = result.get(key).and_then(|v| v.as_object()).cloned() {
420 let mut p = path.to_vec();
421 p.push(segment(key, Map::new()));
422 result.insert(
423 key.into(),
424 process_node(&Value::Object(obj), tool_name, server_name, &p, extractions),
425 );
426 }
427 }
428 if let Some(pp) = result
429 .get("patternProperties")
430 .and_then(|v| v.as_object())
431 .cloned()
432 {
433 let mut new_pp = Map::new();
434 for (pat, sub) in pp {
435 let mut p = path.to_vec();
436 let mut seg = Map::new();
437 seg.insert("type".into(), Value::String("patternProperties".into()));
438 seg.insert("pattern".into(), Value::String(pat.clone()));
439 p.push(seg);
440 new_pp.insert(
441 pat,
442 process_node(&sub, tool_name, server_name, &p, extractions),
443 );
444 }
445 result.insert("patternProperties".into(), Value::Object(new_pp));
446 }
447}
448
449#[must_use]
450pub fn decompose_tool_schema(tool_info: &Value) -> (Value, Vec<Extraction>) {
451 let tool_id = tool_info.get("id").and_then(|v| v.as_str()).unwrap_or("");
452 let t_desc = tool_info
453 .get("full_schema")
454 .and_then(|fs| fs.get("description"))
455 .and_then(|v| v.as_str())
456 .unwrap_or("");
457 let t_schema = tool_info
458 .get("full_schema")
459 .and_then(|fs| fs.get("inputSchema"))
460 .cloned()
461 .unwrap_or(Value::Null);
462 let server = tool_info
463 .get("server")
464 .and_then(|v| v.as_str())
465 .unwrap_or("");
466
467 let mut extractions = Vec::new();
468 let filtered = if t_schema.is_object() {
469 process_node(&t_schema, tool_id, server, &[], &mut extractions)
470 } else {
471 t_schema
472 };
473 let root_schema = json!({
474 "id": tool_id,
475 "name": tool_id,
476 "description": t_desc,
477 "inputSchema": filtered,
478 });
479 (root_schema, extractions)
480}
481
482fn property_relative_path(tool_id: &str, path_segments: &[PathSegment], prop_name: &str) -> String {
483 let prefix = decomposed_prefix().trim_end_matches('/').to_string();
484 let mut parts = vec![prefix, tool_id.to_string()];
485 for seg in path_segments
486 .iter()
487 .take(path_segments.len().saturating_sub(1))
488 {
489 let seg_type = seg.get("type").and_then(|v| v.as_str()).unwrap_or("");
490 match seg_type {
491 "properties" => {
492 if let Some(name) = seg.get("name").and_then(|v| v.as_str()) {
493 parts.push(name.to_string());
494 }
495 }
496 "patternProperties" => {
497 if let Some(pat) = seg.get("pattern").and_then(|v| v.as_str()) {
498 parts.push(pat.to_string());
499 }
500 }
501 _ => {}
502 }
503 }
504 parts.push(format!("{prop_name}{}", json_ext()));
505 parts.join("/")
506}
507
508#[must_use]
510pub fn tool_schema_metadata_from_files<S: BuildHasher>(
511 files: &HashMap<String, String, S>,
512) -> Value {
513 let full = files
514 .get(FULL_METADATA_REL)
515 .and_then(|raw| serde_json::from_str::<Value>(raw).ok());
516 let decomposed = files
517 .get(DECOMPOSED_METADATA_REL)
518 .and_then(|raw| serde_json::from_str::<Value>(raw).ok());
519 json!({
520 "full": full.unwrap_or(Value::Null),
521 "decomposed": decomposed.unwrap_or(Value::Null),
522 })
523}
524
525fn serialize_metadata_json(value: &Value) -> String {
526 let mut serialized = serde_json::to_string_pretty(value).unwrap_or_default();
527 serialized.push('\n');
528 serialized
529}
530
531fn decomposed_metadata_entry_type(rel_path: &str) -> &'static str {
533 if rel_path.ends_with(&md_ext()) {
534 return "enum";
535 }
536 let rest = rel_path
537 .strip_prefix(&decomposed_prefix())
538 .unwrap_or(rel_path);
539 if rest.contains('/') {
540 "property"
541 } else {
542 "tool"
543 }
544}
545
546pub(crate) fn attach_tool_schema_metadata(files: &mut HashMap<String, String>) {
547 let full_prefix = "schemas/full/";
548 let mut full_entries: Vec<Value> = files
549 .iter()
550 .filter(|(rel, _)| rel.starts_with(full_prefix) && rel.ends_with(&json_ext()))
551 .map(|(rel, _content)| {
552 json!({
553 "file_path": rel,
554 "token_count": Value::Null,
555 })
556 })
557 .collect();
558 full_entries.sort_by(|a, b| {
559 a.get("file_path")
560 .and_then(Value::as_str)
561 .unwrap_or("")
562 .cmp(b.get("file_path").and_then(Value::as_str).unwrap_or(""))
563 });
564 if !full_entries.is_empty() {
565 let metadata = if full_entries.len() == 1 {
566 full_entries.into_iter().next().unwrap_or(Value::Null)
567 } else {
568 json!({ "files": full_entries })
569 };
570 files.insert(
571 FULL_METADATA_REL.to_string(),
572 serialize_metadata_json(&metadata),
573 );
574 }
575
576 let decomposed_prefix = decomposed_prefix();
577 let mut decomposed_entries: Vec<Value> = files
578 .iter()
579 .filter(|(rel, _)| {
580 rel.starts_with(&decomposed_prefix)
581 && *rel != DECOMPOSED_METADATA_REL
582 && (rel.ends_with(&json_ext()) || rel.ends_with(&md_ext()))
583 })
584 .map(|(rel, _content)| {
585 json!({
586 "file_path": rel,
587 "type": decomposed_metadata_entry_type(rel),
588 "token_count": Value::Null,
589 })
590 })
591 .collect();
592 decomposed_entries.sort_by(|a, b| {
593 a.get("file_path")
594 .and_then(Value::as_str)
595 .unwrap_or("")
596 .cmp(b.get("file_path").and_then(Value::as_str).unwrap_or(""))
597 });
598 if !decomposed_entries.is_empty() {
599 files.insert(
600 DECOMPOSED_METADATA_REL.to_string(),
601 serialize_metadata_json(&Value::Array(decomposed_entries)),
602 );
603 }
604}
605
606#[must_use]
607pub fn build_catalog_index(tools: &[Value], all_enums: &[Value]) -> CatalogIndex {
608 let mut files = HashMap::new();
609
610 for tool_info in tools {
611 let tool_id = tool_info.get("id").and_then(|v| v.as_str()).unwrap_or("");
612 if let Some(full_schema) = tool_info.get("full_schema") {
613 files.insert(
614 format!("schemas/full/{tool_id}{}", json_ext()),
615 serde_json::to_string_pretty(full_schema).unwrap_or_default(),
616 );
617 }
618 }
619
620 for val in dedupe_enums(all_enums) {
621 let text = enum_markdown_value(&val);
622 let key = format!("{}{text}{}", decomposed_prefix(), md_ext());
623 files.insert(key, text);
624 }
625
626 for tool_info in tools {
627 let tool_id = tool_info.get("id").and_then(|v| v.as_str()).unwrap_or("");
628 let (root_schema, extractions) = decompose_tool_schema(tool_info);
629 files.insert(
630 format!("{}{tool_id}{}", decomposed_prefix(), json_ext()),
631 serde_json::to_string_pretty(&root_schema).unwrap_or_default(),
632 );
633 for (path_segments, prop_schema) in extractions {
634 let prop_name = path_segments
635 .last()
636 .and_then(|s| s.get("name"))
637 .and_then(|v| v.as_str())
638 .unwrap_or("");
639 let rel_path = property_relative_path(tool_id, &path_segments, prop_name);
640 files.insert(
641 rel_path,
642 serde_json::to_string_pretty(&prop_schema).unwrap_or_default(),
643 );
644 }
645 }
646
647 files.insert(
648 "tools.json".into(),
649 serde_json::to_string_pretty(tools).unwrap_or_default(),
650 );
651
652 attach_tool_schema_metadata(&mut files);
653
654 CatalogIndex {
655 tools: tools.to_vec(),
656 files,
657 }
658}