1use serde::{Deserialize, Serialize};
2
3use crate::{ConnectorDescription, Snippet, generate_types};
4
5const LIMIT: usize = 50;
6
7#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
9pub struct SearchResult {
10 pub path: String,
12 pub connector: String,
14 pub method: String,
16 pub description: Option<String>,
18 pub types: String,
20 pub requires_approval: bool,
22 pub kind: String,
24 pub score: u32,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30pub struct SearchOutput {
31 pub results: Vec<SearchResult>,
33 pub total: usize,
35 pub truncated: bool,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41pub struct DescribeOutput {
42 pub path: String,
44 pub description: Option<String>,
46 pub requires_approval: bool,
48 pub types: String,
50 pub kind: String,
52}
53
54pub 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
132pub 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}