Skip to main content

incurs_codemode/
search.rs

1use serde::{Deserialize, Serialize};
2
3use crate::{ConnectorDescription, Snippet, generate_types};
4
5const LIMIT: usize = 50;
6
7/// One ranked connector method or snippet match.
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
9pub struct SearchResult {
10    /// Address used with describe or run.
11    pub path: String,
12    /// Connector namespace, or `snippet`.
13    pub connector: String,
14    /// Connector method or snippet name.
15    pub method: String,
16    /// Human-readable description.
17    pub description: Option<String>,
18    /// TypeScript declarations or snippet source needed to use the match.
19    pub types: String,
20    /// Whether invocation pauses for approval.
21    pub requires_approval: bool,
22    /// Match kind.
23    pub kind: String,
24    /// Weighted relevance score.
25    pub score: u32,
26}
27
28/// Bounded ranked search response.
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30pub struct SearchOutput {
31    /// Ranked matches.
32    pub results: Vec<SearchResult>,
33    /// Total matches before limiting.
34    pub total: usize,
35    /// Whether matches were omitted.
36    pub truncated: bool,
37}
38
39/// Documentation for one connector, method, or snippet.
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41pub struct DescribeOutput {
42    /// Resolved path.
43    pub path: String,
44    /// Human-readable description.
45    pub description: Option<String>,
46    /// Whether invocation pauses for approval.
47    pub requires_approval: bool,
48    /// TypeScript declarations or snippet source.
49    pub types: String,
50    /// Target kind.
51    pub kind: String,
52}
53
54/// Searches connector methods and saved snippets with weighted token matching.
55pub fn search(
56    query: &str,
57    connectors: &[ConnectorDescription],
58    snippets: &[Snippet],
59) -> SearchOutput {
60    let normalized = normalize(query);
61    let tokens = tokenize(query);
62    let mut results = Vec::new();
63    for connector in connectors {
64        for tool in &connector.tools {
65            let path = format!("{}.{}", connector.name, tool.name);
66            if let Some(score) = score(
67                &normalized,
68                &tokens,
69                [
70                    (&path, 12),
71                    (&connector.name, 8),
72                    (&tool.name, 10),
73                    (tool.description.as_deref().unwrap_or_default(), 5),
74                ],
75            ) {
76                results.push(SearchResult {
77                    path,
78                    connector: connector.name.clone(),
79                    method: tool.name.clone(),
80                    description: tool.description.clone(),
81                    types: {
82                        let mut single = connector.clone();
83                        single.instructions = None;
84                        single.tools = vec![tool.clone()];
85                        generate_types(&single)
86                    },
87                    requires_approval: tool.policy.requires_approval,
88                    kind: "method".to_string(),
89                    score,
90                });
91            }
92        }
93    }
94    for snippet in snippets {
95        if let Some(score) = score(
96            &normalized,
97            &tokens,
98            [
99                (&snippet.name, 12),
100                ("snippet", 8),
101                (&snippet.name, 10),
102                (&snippet.description, 5),
103            ],
104        ) {
105            results.push(SearchResult {
106                path: snippet.name.clone(),
107                connector: "snippet".to_string(),
108                method: snippet.name.clone(),
109                description: Some(snippet.description.clone()),
110                types: snippet.code.clone(),
111                requires_approval: false,
112                kind: "snippet".to_string(),
113                score,
114            });
115        }
116    }
117    results.sort_by(|left, right| {
118        right
119            .score
120            .cmp(&left.score)
121            .then_with(|| left.path.cmp(&right.path))
122    });
123    let total = results.len();
124    results.truncate(LIMIT);
125    SearchOutput {
126        results,
127        total,
128        truncated: total > LIMIT,
129    }
130}
131
132/// Describes one connector, method, or saved snippet.
133pub fn describe(
134    target: &str,
135    connectors: &[ConnectorDescription],
136    snippets: &[Snippet],
137) -> DescribeOutput {
138    if let Some(snippet) = snippets.iter().find(|snippet| snippet.name == target) {
139        return DescribeOutput {
140            path: target.to_string(),
141            description: Some(snippet.description.clone()),
142            requires_approval: false,
143            types: format!("{}\n\n```ts\n{}\n```", snippet.description, snippet.code),
144            kind: "snippet".to_string(),
145        };
146    }
147    let (connector_name, method_name) = target
148        .split_once('.')
149        .map(|(connector, method)| (Some(connector), method))
150        .unwrap_or((None, target));
151    if connector_name.is_none()
152        && let Some(connector) = connectors.iter().find(|connector| connector.name == target)
153    {
154        return DescribeOutput {
155            path: target.to_string(),
156            description: connector.instructions.clone(),
157            requires_approval: false,
158            types: generate_types(connector),
159            kind: "connector".to_string(),
160        };
161    }
162    for connector in connectors
163        .iter()
164        .filter(|connector| connector_name.is_none_or(|name| connector.name == name))
165    {
166        if let Some(tool) = connector.tools.iter().find(|tool| tool.name == method_name) {
167            let mut single = connector.clone();
168            single.instructions = None;
169            single.tools = vec![tool.clone()];
170            return DescribeOutput {
171                path: format!("{}.{}", connector.name, tool.name),
172                description: tool.description.clone(),
173                requires_approval: tool.policy.requires_approval,
174                types: generate_types(&single),
175                kind: "method".to_string(),
176            };
177        }
178    }
179    DescribeOutput {
180        path: target.to_string(),
181        description: None,
182        requires_approval: false,
183        types: format!("\"{target}\" not found."),
184        kind: "method".to_string(),
185    }
186}
187
188fn score<const COUNT: usize>(
189    query: &str,
190    query_tokens: &[String],
191    fields: [(&str, u32); COUNT],
192) -> Option<u32> {
193    if query.is_empty() || query_tokens.is_empty() {
194        return None;
195    }
196    let mut score = 0;
197    let mut matched = vec![false; query_tokens.len()];
198    let mut phrase = false;
199    for (value, weight) in fields {
200        let value = normalize(value);
201        let tokens = tokenize(&value);
202        if value == query {
203            score += weight * 14;
204        } else if value.starts_with(query) {
205            score += weight * 9;
206        } else if value.contains(query) {
207            score += weight * 6;
208            phrase = true;
209        }
210        for (index, token) in query_tokens.iter().enumerate() {
211            if tokens.contains(token) {
212                score += weight * 4;
213                matched[index] = true;
214            } else if tokens
215                .iter()
216                .any(|candidate| candidate.starts_with(token) || token.starts_with(candidate))
217            {
218                score += weight * 2;
219                matched[index] = true;
220            } else if value.contains(token) {
221                score += weight;
222                matched[index] = true;
223            }
224        }
225    }
226    let matched_count = matched.iter().filter(|matched| **matched).count();
227    if matched_count == 0 {
228        return None;
229    }
230    let coverage = matched_count as f32 / query_tokens.len() as f32;
231    if coverage < if query_tokens.len() <= 2 { 1.0 } else { 0.6 } && !phrase {
232        return None;
233    }
234    Some(
235        score
236            + if matched_count == query_tokens.len() {
237                25
238            } else {
239                10
240            },
241    )
242}
243
244fn normalize(value: &str) -> String {
245    let mut result = String::new();
246    let mut previous_lower = false;
247    for ch in value.chars() {
248        if ch.is_ascii_uppercase() && previous_lower {
249            result.push(' ');
250        }
251        if ch.is_ascii_alphanumeric() {
252            result.push(ch.to_ascii_lowercase());
253            previous_lower = ch.is_ascii_lowercase() || ch.is_ascii_digit();
254        } else {
255            result.push(' ');
256            previous_lower = false;
257        }
258    }
259    result.split_whitespace().collect::<Vec<_>>().join(" ")
260}
261
262fn tokenize(value: &str) -> Vec<String> {
263    normalize(value)
264        .split_whitespace()
265        .map(str::to_string)
266        .collect()
267}
268
269#[cfg(test)]
270mod tests {
271    use serde_json::json;
272
273    use crate::{ConnectorTool, ToolAnnotations};
274
275    use super::*;
276
277    #[test]
278    fn ranks_exact_method_matches_first() {
279        let connectors = [ConnectorDescription {
280            name: "github".to_string(),
281            instructions: None,
282            tools: vec![ConnectorTool {
283                name: "listIssues".to_string(),
284                description: Some("List repository issues".to_string()),
285                input_schema: json!({"type": "object"}),
286                output_schema: None,
287                instructions: None,
288                examples: Vec::new(),
289                annotations: ToolAnnotations::default(),
290                policy: crate::ToolPolicy::default(),
291            }],
292        }];
293        let result = search("list issues", &connectors, &[]);
294        assert_eq!(result.results[0].path, "github.listIssues");
295        assert!(result.results[0].types.contains("listIssues"));
296    }
297}