Skip to main content

skiff_cli/
tools_index.rs

1//! Compact tool catalog index for warm `--search` / `--detail names`.
2//!
3//! Full MCP tool lists embed every `inputSchema` (multi‑MB). We store a small
4//! parallel index:
5//!
6//! - **names** (sorted) — exact/prefix via binary search
7//! - **tool_overrides** — sparse MCP names when they are not kebab→snake
8//! - **postings** — kebab-segment → tool ids (in memory only; rebuilt on load)
9//! - **descs** — optional truncated text (omitted from v4 disk by default)
10//!
11//! Session daemons keep this struct in RAM and search in-process. Disk is a
12//! thin non-session accelerator (names + overrides only).
13//!
14//! The inverted `postings` map is the KV shape we need at ~3k tools. An embedded
15//! KV engine (redb/sled) is not worth the deps unless catalogs grow far larger
16//! or we need a shared index without a session daemon.
17//!
18//! A BST does **not** help arbitrary substring search. Sorted names + postings
19//! are the right shape for CLI discovery.
20
21use 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
31/// Max description chars retained when building with `with_descs: true`.
32pub const DESC_TRUNCATE: usize = 80;
33
34/// On-disk format: names + sparse overrides; postings rebuilt in memory.
35pub 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/// Compact index (v4 disk omits `descs`/`postings`; v2/v3 still load).
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct CompactIndex {
47    pub v: u32,
48    /// Sorted kebab CLI names (binary-searchable for exact/prefix).
49    pub names: Vec<String>,
50    /// Legacy v2: parallel MCP tool names (omitted in v3+).
51    #[serde(default, skip_serializing_if = "Vec::is_empty")]
52    pub tool_names: Vec<String>,
53    /// Sparse MCP names when `tool_name != names[i].replace('-', '_')`.
54    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
55    pub tool_overrides: BTreeMap<u32, String>,
56    /// Truncated descriptions; empty on v4 disk / names-only builds.
57    #[serde(default, skip_serializing_if = "Vec::is_empty")]
58    pub descs: Vec<String>,
59    /// Kebab segment → tool indices. Not persisted in v4; rebuild on load.
60    #[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
72/// Rebuild inverted postings from `names` (used after v4 disk load).
73pub 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    /// MCP-shaped light tools (`name` = wire tool name) for IPC / light commands.
128    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
215/// Persist names + overrides only (no descs / postings) for a slim disk sidecar.
216pub 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    // Legacy v1: array of {name,tool_name,description}
239    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
285/// Search using postings when `pattern` is a single kebab token; else scan names.
286pub 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
344/// Exact name lookup via binary search on sorted names.
345pub 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
353/// Warm-path commands from disk index.
354///
355/// When `require_descs` is true (`--detail brief`) and the index has no
356/// descriptions (v4 default), returns `None` so the caller can fall through.
357pub 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}