1use nucleo_matcher::pattern::{AtomKind, CaseMatching, Normalization, Pattern};
2use nucleo_matcher::{Config, Matcher};
3
4use crate::models::{Agent, Skill};
5
6#[derive(Debug, Clone)]
8pub struct SearchResult {
9 pub index: usize,
10 pub score: u32,
11}
12
13pub struct SearchEngine {
31 matcher: Matcher,
32}
33
34impl SearchEngine {
35 pub fn new() -> Self {
36 SearchEngine {
37 matcher: Matcher::new(Config::DEFAULT),
38 }
39 }
40
41 pub fn search_agents(
62 &mut self,
63 query: &str,
64 agents: &[Agent],
65 provider_filter: Option<&str>,
66 harness_filter: Option<&str>,
67 ) -> Vec<usize> {
68 if query.is_empty() && provider_filter.is_none() && harness_filter.is_none() {
69 return (0..agents.len()).collect();
70 }
71
72 let candidates: Vec<usize> = agents
74 .iter()
75 .enumerate()
76 .filter(|(_, agent)| {
77 if let Some(pf) = provider_filter {
78 let provider_str = serde_json::to_value(agent.provider)
79 .ok()
80 .and_then(|v| v.as_str().map(|s| s.to_string()))
81 .unwrap_or_default();
82 if provider_str.to_lowercase() != pf.to_lowercase() {
83 return false;
84 }
85 }
86 if let Some(hf) = harness_filter {
87 let has_harness = agent.harnesses.iter().any(|h| {
88 let h_str = serde_json::to_value(h)
89 .ok()
90 .and_then(|v| v.as_str().map(|s| s.to_string()))
91 .unwrap_or_default();
92 h_str.to_lowercase() == hf.to_lowercase()
93 });
94 if !has_harness {
95 return false;
96 }
97 }
98 true
99 })
100 .map(|(i, _)| i)
101 .collect();
102
103 if query.is_empty() {
104 return candidates;
105 }
106
107 let pattern = Pattern::new(
110 query,
111 CaseMatching::Ignore,
112 Normalization::Smart,
113 AtomKind::Fuzzy,
114 );
115
116 let mut results: Vec<SearchResult> = candidates
117 .into_iter()
118 .filter_map(|idx| {
119 let agent = &agents[idx];
120 let provider_str = serde_json::to_value(agent.provider)
121 .ok()
122 .and_then(|v| v.as_str().map(|s| s.to_string()))
123 .unwrap_or_default();
124 let haystack = format!(
126 "{} {} {} {}",
127 agent.id, agent.name, provider_str, agent.summary
128 );
129 let mut buf = Vec::new();
130 let score = pattern.score(
131 nucleo_matcher::Utf32Str::new(&haystack, &mut buf),
132 &mut self.matcher,
133 )?;
134 Some(SearchResult { index: idx, score })
135 })
136 .collect();
137
138 results.sort_by(|a, b| b.score.cmp(&a.score).then(a.index.cmp(&b.index)));
141 results.into_iter().map(|r| r.index).collect()
142 }
143
144 pub fn search_skills(&mut self, query: &str, skills: &[Skill]) -> Vec<usize> {
157 if query.is_empty() {
158 return (0..skills.len()).collect();
159 }
160
161 let pattern = Pattern::new(
162 query,
163 CaseMatching::Ignore,
164 Normalization::Smart,
165 AtomKind::Fuzzy,
166 );
167
168 let mut results: Vec<SearchResult> = skills
169 .iter()
170 .enumerate()
171 .filter_map(|(idx, skill)| {
172 let haystack = format!("{} {} {}", skill.id, skill.name, skill.summary);
174 let mut buf = Vec::new();
175 let score = pattern.score(
176 nucleo_matcher::Utf32Str::new(&haystack, &mut buf),
177 &mut self.matcher,
178 )?;
179 Some(SearchResult { index: idx, score })
180 })
181 .collect();
182
183 results.sort_by(|a, b| b.score.cmp(&a.score).then(a.index.cmp(&b.index)));
184 results.into_iter().map(|r| r.index).collect()
185 }
186}
187
188impl Default for SearchEngine {
189 fn default() -> Self {
190 Self::new()
191 }
192}
193
194#[cfg(test)]
199mod tests {
200 use super::*;
201 use crate::catalog::store::CatalogStore;
202 use crate::models::{Agent, Skill};
203 use std::path::Path;
204
205 fn workspace_root() -> &'static Path {
206 let manifest_dir = env!("CARGO_MANIFEST_DIR");
207 let p = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
208 Box::leak(Box::new(p.to_path_buf()))
209 }
210
211 #[test]
214 fn search_agents_empty_query_returns_all() {
215 let store = CatalogStore::load(workspace_root());
216 let mut engine = SearchEngine::new();
217 let results = engine.search_agents("", &store.agents, None, None);
218 assert_eq!(results.len(), store.agents.len());
219 }
220
221 #[test]
222 fn search_agents_with_query() {
223 let store = CatalogStore::load(workspace_root());
224 let mut engine = SearchEngine::new();
225 let results = engine.search_agents("aws", &store.agents, None, None);
226 assert!(!results.is_empty());
227 assert!(results.len() <= store.agents.len());
229 }
230
231 #[test]
232 fn search_agents_with_provider_filter() {
233 let store = CatalogStore::load(workspace_root());
234 let mut engine = SearchEngine::new();
235 let results = engine.search_agents("", &store.agents, Some("aws"), None);
236 assert!(!results.is_empty());
237 for idx in &results {
239 let provider_str = serde_json::to_value(store.agents[*idx].provider)
240 .ok()
241 .and_then(|v| v.as_str().map(|s| s.to_string()))
242 .unwrap_or_default();
243 assert_eq!(provider_str, "aws");
244 }
245 }
246
247 #[test]
248 fn search_skills_with_query() {
249 let store = CatalogStore::load(workspace_root());
250 let mut engine = SearchEngine::new();
251 let results = engine.search_skills("cost", &store.skills);
252 assert!(!results.is_empty());
253 }
254
255 #[test]
256 fn search_skills_empty_query_returns_all() {
257 let store = CatalogStore::load(workspace_root());
258 let mut engine = SearchEngine::new();
259 let results = engine.search_skills("", &store.skills);
260 assert_eq!(results.len(), store.skills.len());
261 }
262
263 fn make_agent(id: &str, name: &str, provider_str: &str, summary: &str) -> Agent {
271 let json = format!(
275 r#"{{
276 "id": "{id}",
277 "name": "{name}",
278 "type": "agent",
279 "provider": {provider_str},
280 "harnesses": [],
281 "summary": "{summary}",
282 "source_type": "original",
283 "official_docs": [],
284 "security_notes": "",
285 "last_verified": "2026-01-01",
286 "path": "agents/test/{id}",
287 "companion_skills": []
288 }}"#
289 );
290 serde_json::from_str(&json)
291 .unwrap_or_else(|e| panic!("make_agent json parse failed for id={id}: {e}"))
292 }
293
294 fn make_skill(id: &str, name: &str, summary: &str) -> Skill {
296 let json = format!(
299 r#"{{
300 "id": "{id}",
301 "name": "{name}",
302 "type": "skill",
303 "provider": "aws",
304 "harnesses": [],
305 "summary": "{summary}",
306 "source_type": "original",
307 "official_docs": [],
308 "security_notes": "",
309 "last_verified": "2026-01-01",
310 "path": "skills/test/{id}"
311 }}"#
312 );
313 serde_json::from_str(&json)
314 .unwrap_or_else(|e| panic!("make_skill json parse failed for id={id}: {e}"))
315 }
316
317 #[test]
320 fn search_agents_matches_by_id_substring() {
321 let agents = vec![
322 make_agent("iam-guard", "IAM Guard", "\"aws\"", "Monitors IAM"),
323 make_agent(
324 "cost-opt",
325 "Cost Optimizer",
326 "\"aws\"",
327 "Reduces cloud spend",
328 ),
329 ];
330 let mut engine = SearchEngine::new();
331 let results = engine.search_agents("iam", &agents, None, None);
332 assert!(
333 results.contains(&0),
334 "agent with 'iam' in id should be returned; got {results:?}"
335 );
336 }
337
338 #[test]
341 fn search_agents_matches_by_name_substring() {
342 let agents = vec![
343 make_agent("agent-x", "Security Scanner", "\"aws\"", "Scans for vulns"),
344 make_agent(
345 "agent-y",
346 "Cost Optimizer",
347 "\"gcp\"",
348 "Reduces cloud spend",
349 ),
350 ];
351 let mut engine = SearchEngine::new();
352 let results = engine.search_agents("scanner", &agents, None, None);
353 assert!(
354 results.contains(&0),
355 "'scanner' should match agent with name 'Security Scanner'; got {results:?}"
356 );
357 }
358
359 #[test]
362 fn search_agents_matches_by_provider_substring() {
363 let agents = vec![
364 make_agent("agent-a", "Some Agent", "\"azure\"", "Does Azure things"),
365 make_agent("agent-b", "Other Agent", "\"aws\"", "Does AWS things"),
366 ];
367 let mut engine = SearchEngine::new();
368 let results = engine.search_agents("azure", &agents, None, None);
370 assert!(
371 results.contains(&0),
372 "'azure' should match via provider field; got {results:?}"
373 );
374 }
377
378 #[test]
381 fn search_agents_matches_by_summary_substring() {
382 let agents = vec![
383 make_agent("agent-a", "Agent Alpha", "\"aws\"", "Detects policy drift"),
384 make_agent(
385 "agent-b",
386 "Agent Beta",
387 "\"gcp\"",
388 "Optimizes resource usage",
389 ),
390 ];
391 let mut engine = SearchEngine::new();
392 let results = engine.search_agents("drift", &agents, None, None);
393 assert!(
394 results.contains(&0),
395 "'drift' should match agent with 'policy drift' in summary; got {results:?}"
396 );
397 }
398
399 #[test]
402 fn search_agents_empty_query_no_filters_returns_all_in_order() {
403 let agents = vec![
404 make_agent("z-agent", "Z Agent", "\"aws\"", "Last"),
405 make_agent("a-agent", "A Agent", "\"gcp\"", "First"),
406 ];
407 let mut engine = SearchEngine::new();
408 let results = engine.search_agents("", &agents, None, None);
409 assert_eq!(results, vec![0, 1]);
411 }
412
413 #[test]
416 fn search_skills_matches_by_id_substring() {
417 let skills = vec![
418 make_skill("cost-tracker", "Cost Tracker", "Tracks cloud costs"),
419 make_skill("sec-scanner", "Security Scanner", "Scans for issues"),
420 ];
421 let mut engine = SearchEngine::new();
422 let results = engine.search_skills("cost", &skills);
423 assert!(
424 results.contains(&0),
425 "'cost' should match skill with id 'cost-tracker'; got {results:?}"
426 );
427 }
428
429 #[test]
432 fn search_skills_matches_by_name_substring() {
433 let skills = vec![
434 make_skill("sk-1", "Drift Detector", "Detects configuration drift"),
435 make_skill("sk-2", "Log Analyzer", "Parses log files"),
436 ];
437 let mut engine = SearchEngine::new();
438 let results = engine.search_skills("detector", &skills);
439 assert!(
440 results.contains(&0),
441 "'detector' should match skill with name 'Drift Detector'; got {results:?}"
442 );
443 }
444
445 #[test]
448 fn search_skills_matches_by_summary_substring() {
449 let skills = vec![
450 make_skill("sk-a", "Alpha Skill", "Validates compliance posture"),
451 make_skill("sk-b", "Beta Skill", "Generates coverage reports"),
452 ];
453 let mut engine = SearchEngine::new();
454 let results = engine.search_skills("compliance", &skills);
455 assert!(
456 results.contains(&0),
457 "'compliance' should match skill with 'compliance' in summary; got {results:?}"
458 );
459 }
460
461 #[test]
464 fn search_skills_empty_query_returns_all_in_order() {
465 let skills = vec![
466 make_skill("z-skill", "Z Skill", "Last"),
467 make_skill("a-skill", "A Skill", "First"),
468 ];
469 let mut engine = SearchEngine::new();
470 let results = engine.search_skills("", &skills);
471 assert_eq!(results, vec![0, 1]);
472 }
473
474 #[test]
477 fn results_sorted_by_score_descending() {
478 let agents = vec![
480 make_agent(
481 "sec-audit",
482 "Security Audit",
483 "\"aws\"",
484 "Detailed audit tool",
485 ),
486 make_agent(
487 "sec-basic",
488 "Security Basic",
489 "\"aws\"",
490 "Basic security check",
491 ),
492 ];
493 let mut engine = SearchEngine::new();
494 let results = engine.search_agents("sec audit", &agents, None, None);
496 if results.len() >= 2 {
497 assert_eq!(results[0], 0, "sec-audit should rank first for 'sec audit'");
500 }
501 assert!(results.contains(&0), "sec-audit must be in results");
503 }
504}