1use std::collections::BTreeMap;
22
23use serde::{Deserialize, Serialize};
24use serde_json::Value;
25
26use crate::cache::{load_cached, save_cache};
27use crate::coerce::to_kebab;
28use crate::error::Result;
29use crate::model::CommandDef;
30
31pub const DESC_TRUNCATE: usize = 80;
33
34pub const INDEX_VERSION: u32 = 4;
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct ToolIndexEntry {
39 pub name: String,
40 pub tool_name: String,
41 pub description: String,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct CompactIndex {
47 pub v: u32,
48 pub names: Vec<String>,
50 #[serde(default, skip_serializing_if = "Vec::is_empty")]
52 pub tool_names: Vec<String>,
53 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
55 pub tool_overrides: BTreeMap<u32, String>,
56 #[serde(default, skip_serializing_if = "Vec::is_empty")]
58 pub descs: Vec<String>,
59 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
61 pub postings: BTreeMap<String, Vec<u32>>,
62}
63
64fn default_tool_name_from_kebab(kebab: &str) -> String {
65 kebab.replace('-', "_")
66}
67
68fn tokens_of(name: &str) -> impl Iterator<Item = &str> {
69 name.split('-').filter(|t| !t.is_empty())
70}
71
72pub fn rebuild_postings(index: &mut CompactIndex) {
74 let mut postings: BTreeMap<String, Vec<u32>> = BTreeMap::new();
75 for (i, name) in index.names.iter().enumerate() {
76 for tok in tokens_of(name) {
77 postings.entry(tok.to_string()).or_default().push(i as u32);
78 }
79 }
80 index.postings = postings;
81}
82
83impl CompactIndex {
84 pub fn len(&self) -> usize {
85 self.names.len()
86 }
87
88 pub fn is_empty(&self) -> bool {
89 self.names.is_empty()
90 }
91
92 pub fn has_descs(&self) -> bool {
93 !self.descs.is_empty()
94 }
95
96 pub fn tool_name_at(&self, i: usize) -> String {
97 if let Some(t) = self.tool_overrides.get(&(i as u32)) {
98 return t.clone();
99 }
100 if let Some(t) = self
101 .tool_names
102 .get(i)
103 .map(String::as_str)
104 .filter(|s| !s.is_empty())
105 {
106 return t.to_string();
107 }
108 default_tool_name_from_kebab(&self.names[i])
109 }
110
111 pub fn desc_at(&self, i: usize) -> &str {
112 self.descs.get(i).map(String::as_str).unwrap_or("")
113 }
114
115 pub fn entry_at(&self, i: usize) -> ToolIndexEntry {
116 ToolIndexEntry {
117 name: self.names[i].clone(),
118 tool_name: self.tool_name_at(i),
119 description: self.desc_at(i).to_string(),
120 }
121 }
122
123 pub fn to_entries(&self) -> Vec<ToolIndexEntry> {
124 (0..self.len()).map(|i| self.entry_at(i)).collect()
125 }
126
127 pub fn to_light_tools_json(&self, entries: &[ToolIndexEntry]) -> Value {
129 Value::Array(
130 entries
131 .iter()
132 .map(|e| {
133 serde_json::json!({
134 "name": e.tool_name,
135 "description": e.description,
136 })
137 })
138 .collect(),
139 )
140 }
141}
142
143pub fn index_cache_key(tools_cache_key: &str) -> String {
144 format!("{tools_cache_key}_index")
145}
146
147fn truncate_desc(s: &str, max: usize) -> String {
148 if s.chars().count() <= max {
149 return s.to_string();
150 }
151 let mut out: String = s.chars().take(max).collect();
152 if let Some(i) = out.rfind(' ') {
153 out.truncate(i);
154 }
155 out.push_str("...");
156 out
157}
158
159pub fn build_compact_index(tools: &[Value], with_descs: bool) -> CompactIndex {
160 let mut rows: Vec<(String, String, String)> = Vec::with_capacity(tools.len());
161 for tool in tools {
162 let tool_name = tool
163 .get("name")
164 .and_then(|v| v.as_str())
165 .unwrap_or("unknown")
166 .to_string();
167 let name = to_kebab(&tool_name);
168 let description = if with_descs {
169 truncate_desc(
170 tool.get("description")
171 .and_then(|v| v.as_str())
172 .unwrap_or(""),
173 DESC_TRUNCATE,
174 )
175 } else {
176 String::new()
177 };
178 rows.push((name, tool_name, description));
179 }
180 rows.sort_by(|a, b| a.0.cmp(&b.0));
181
182 let mut names = Vec::with_capacity(rows.len());
183 let mut tool_overrides: BTreeMap<u32, String> = BTreeMap::new();
184 let mut descs = Vec::new();
185 let mut postings: BTreeMap<String, Vec<u32>> = BTreeMap::new();
186
187 for (i, (name, tool_name, description)) in rows.into_iter().enumerate() {
188 let guessed = default_tool_name_from_kebab(&name);
189 if tool_name != guessed {
190 tool_overrides.insert(i as u32, tool_name);
191 }
192 for tok in tokens_of(&name) {
193 postings.entry(tok.to_string()).or_default().push(i as u32);
194 }
195 names.push(name);
196 if with_descs {
197 descs.push(description);
198 }
199 }
200
201 CompactIndex {
202 v: INDEX_VERSION,
203 names,
204 tool_names: Vec::new(),
205 tool_overrides,
206 descs,
207 postings,
208 }
209}
210
211pub fn build_index(tools: &[Value]) -> Vec<ToolIndexEntry> {
212 build_compact_index(tools, true).to_entries()
213}
214
215pub fn save_index(tools_cache_key: &str, tools: &[Value]) -> Result<()> {
217 let mut index = build_compact_index(tools, false);
218 index.postings.clear();
219 index.descs.clear();
220 save_cache(
221 &index_cache_key(tools_cache_key),
222 &serde_json::to_value(&index)?,
223 )
224}
225
226pub fn load_compact_index(tools_cache_key: &str, ttl: u64) -> Result<Option<CompactIndex>> {
227 let Some(v) = load_cached(&index_cache_key(tools_cache_key), ttl)? else {
228 return Ok(None);
229 };
230 let ver = v.get("v").and_then(|x| x.as_u64());
231 if matches!(ver, Some(2) | Some(3) | Some(4)) && v.get("names").is_some() {
232 let mut idx: CompactIndex = serde_json::from_value(v)?;
233 if idx.postings.is_empty() {
234 rebuild_postings(&mut idx);
235 }
236 return Ok(Some(idx));
237 }
238 if let Ok(entries) = serde_json::from_value::<Vec<ToolIndexEntry>>(v) {
240 return Ok(Some(legacy_entries_to_compact(entries)));
241 }
242 Ok(None)
243}
244
245fn legacy_entries_to_compact(mut entries: Vec<ToolIndexEntry>) -> CompactIndex {
246 entries.sort_by(|a, b| a.name.cmp(&b.name));
247 let tools: Vec<Value> = entries
248 .iter()
249 .map(|e| {
250 serde_json::json!({
251 "name": e.tool_name,
252 "description": e.description,
253 })
254 })
255 .collect();
256 build_compact_index(&tools, true)
257}
258
259pub fn load_index(tools_cache_key: &str, ttl: u64) -> Result<Option<Vec<ToolIndexEntry>>> {
260 Ok(load_compact_index(tools_cache_key, ttl)?.map(|c| c.to_entries()))
261}
262
263pub fn save_tools_and_index(tools_cache_key: &str, tools: &[Value]) -> Result<()> {
264 save_cache(tools_cache_key, &Value::Array(tools.to_vec()))?;
265 save_index(tools_cache_key, tools)?;
266 Ok(())
267}
268
269pub fn index_to_commands(entries: &[ToolIndexEntry]) -> Vec<CommandDef> {
270 entries
271 .iter()
272 .map(|e| CommandDef {
273 name: e.name.clone(),
274 description: e.description.clone(),
275 tool_name: Some(e.tool_name.clone()),
276 ..Default::default()
277 })
278 .collect()
279}
280
281pub fn tools_to_light_commands(tools: &[Value]) -> Vec<CommandDef> {
282 index_to_commands(&build_index(tools))
283}
284
285pub fn search_compact(index: &CompactIndex, pattern: &str) -> Vec<ToolIndexEntry> {
287 let p = pattern.to_lowercase();
288 let ids = if !p.is_empty() && !p.contains('-') && !p.contains(' ') {
289 if let Some(list) = index.postings.get(&p) {
290 list.clone()
291 } else {
292 let mut acc: Vec<u32> = index
293 .postings
294 .iter()
295 .filter(|(k, _)| k.starts_with(&p) || k.contains(&p))
296 .flat_map(|(_, v)| v.iter().copied())
297 .collect();
298 acc.sort_unstable();
299 acc.dedup();
300 if acc.is_empty() {
301 return scan_names(index, &p);
302 }
303 acc
304 }
305 } else {
306 return scan_names(index, &p);
307 };
308
309 ids.into_iter()
310 .filter_map(|i| {
311 let i = i as usize;
312 (i < index.len()).then(|| index.entry_at(i))
313 })
314 .collect()
315}
316
317fn scan_names(index: &CompactIndex, p: &str) -> Vec<ToolIndexEntry> {
318 index
319 .names
320 .iter()
321 .enumerate()
322 .filter(|(i, n)| {
323 n.contains(p)
324 || index.tool_name_at(*i).to_lowercase().contains(p)
325 || index.desc_at(*i).to_lowercase().contains(p)
326 })
327 .map(|(i, _)| index.entry_at(i))
328 .collect()
329}
330
331pub fn search_index(entries: &[ToolIndexEntry], pattern: &str) -> Vec<ToolIndexEntry> {
332 let p = pattern.to_lowercase();
333 entries
334 .iter()
335 .filter(|e| {
336 e.name.to_lowercase().contains(&p)
337 || e.tool_name.to_lowercase().contains(&p)
338 || e.description.to_lowercase().contains(&p)
339 })
340 .cloned()
341 .collect()
342}
343
344pub fn find_exact(index: &CompactIndex, name: &str) -> Option<ToolIndexEntry> {
346 index
347 .names
348 .binary_search_by(|n| n.as_str().cmp(name))
349 .ok()
350 .map(|i| index.entry_at(i))
351}
352
353pub fn try_commands_from_index(
358 tools_cache_key: &str,
359 ttl: u64,
360 search: Option<&str>,
361 require_descs: bool,
362) -> Result<Option<Vec<CommandDef>>> {
363 let Some(index) = load_compact_index(tools_cache_key, ttl)? else {
364 return Ok(None);
365 };
366 if require_descs && !index.has_descs() {
367 return Ok(None);
368 }
369 let entries = if let Some(pat) = search {
370 search_compact(&index, pat)
371 } else {
372 index.to_entries()
373 };
374 Ok(Some(index_to_commands(&entries)))
375}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380 use serde_json::json;
381
382 #[test]
383 fn build_and_search_postings() {
384 let tools = vec![
385 json!({"name": "workers_list", "description": "List workers", "inputSchema": {"type":"object","properties":{"a":{"type":"string"}}}}),
386 json!({"name": "dns_records", "description": "DNS stuff", "inputSchema": {}}),
387 json!({"name": "workers_scripts_get", "description": "Get script", "inputSchema": {}}),
388 ];
389 let idx = build_compact_index(&tools, false);
390 assert_eq!(idx.len(), 3);
391 assert!(idx.names.windows(2).all(|w| w[0] <= w[1]));
392 assert!(idx.postings.contains_key("workers"));
393 let hit = search_compact(&idx, "workers");
394 assert_eq!(hit.len(), 2);
395 assert!(find_exact(&idx, "dns-records").is_some());
396 }
397
398 #[test]
399 fn v4_disk_omits_postings_rebuild_on_load() {
400 let tools = vec![
401 json!({"name": "workers_list", "description": "List workers"}),
402 json!({"name": "dns_records", "description": "DNS"}),
403 ];
404 let mut disk = build_compact_index(&tools, false);
405 disk.postings.clear();
406 disk.descs.clear();
407 let v = serde_json::to_value(&disk).unwrap();
408 assert!(
409 v.get("postings").is_none()
410 || v.get("postings").unwrap().as_object().unwrap().is_empty()
411 );
412 let mut loaded: CompactIndex = serde_json::from_value(v).unwrap();
413 assert!(loaded.postings.is_empty());
414 rebuild_postings(&mut loaded);
415 assert!(loaded.postings.contains_key("workers"));
416 assert_eq!(search_compact(&loaded, "workers").len(), 1);
417 }
418
419 #[test]
420 fn compact_disk_smaller_than_naive_array() {
421 let tools: Vec<Value> = (0..50)
422 .map(|i| {
423 json!({
424 "name": format!("workers_scripts_op_{i}"),
425 "description": "x".repeat(200),
426 "inputSchema": {"type":"object","properties":{"a":{"type":"string"},"b":{"type":"integer"}}},
427 })
428 })
429 .collect();
430 let mut disk = build_compact_index(&tools, false);
431 disk.postings.clear();
432 let compact = serde_json::to_vec(&disk).unwrap();
433 let naive = serde_json::to_vec(&build_index(&tools)).unwrap();
434 assert!(
435 compact.len() < naive.len() / 2,
436 "disk compact {} vs naive {}",
437 compact.len(),
438 naive.len()
439 );
440 }
441
442 #[test]
443 fn snake_default_skips_tool_names_array() {
444 let tools = vec![
445 json!({"name": "workers_list", "description": "a"}),
446 json!({"name": "get_accounts_scim_v2_Groups", "description": "b"}),
447 ];
448 let idx = build_compact_index(&tools, false);
449 assert!(idx.tool_names.is_empty());
450 let workers = idx.names.iter().position(|n| n == "workers-list").unwrap();
451 assert_eq!(idx.tool_name_at(workers), "workers_list");
452 let groups = idx
453 .names
454 .iter()
455 .position(|n| n == "get-accounts-scim-v2-groups")
456 .unwrap();
457 assert_eq!(idx.tool_name_at(groups), "get_accounts_scim_v2_Groups");
458 assert_eq!(idx.tool_overrides.len(), 1);
459 }
460
461 #[test]
462 fn require_descs_skips_names_only_index() {
463 let tools = vec![json!({"name": "workers_list", "description": "hi"})];
464 let idx = build_compact_index(&tools, false);
465 assert!(!idx.has_descs());
466 }
467}