Skip to main content

chunk_your_tools/
build.rs

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/// Parse a catalog index from a JSON value (`{ "tools": [...], "files": {...} }`).
16#[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    /// Return cached full/decomposed tool schema token metadata when present.
47    #[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
152/// Plain-text form for decomposed enum `.md` files (matches Python ``str(val)``).
153fn 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/// Read cached tool schema token metadata from a catalog index file table.
509#[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
531/// Classify a decomposed catalog file for `schemas/decomposed/metadata.json`.
532fn 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}