Skip to main content

vfa_tui/search/
fuzzy.rs

1use nucleo_matcher::pattern::{AtomKind, CaseMatching, Normalization, Pattern};
2use nucleo_matcher::{Config, Matcher};
3
4use crate::models::{Agent, Skill};
5
6/// Search result with index and match score.
7#[derive(Debug, Clone)]
8pub struct SearchResult {
9    pub index: usize,
10    pub score: u32,
11}
12
13/// Fuzzy search engine backed by `nucleo-matcher`.
14///
15/// ### Field coverage
16///
17/// Agent searches match against a single concatenated haystack built as:
18/// `"{id} {name} {provider} {summary}"` — covering all four fields required
19/// by Req 16.2 and 32.2.
20///
21/// Skill searches match against `"{id} {name} {summary}"` — id, name, and
22/// summary as required by Req 32.2.
23///
24/// ### Performance
25///
26/// For catalogs of < 100 assets (typical in this repository), fuzzy matching
27/// completes well within the 100 ms budget stated in Req 16.2.  No explicit
28/// benchmarking infrastructure is included here; the design relies on
29/// `nucleo-matcher`'s O(nm) algorithm being fast enough for this dataset size.
30pub 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    /// Search agents by fuzzy matching against id, name, provider, and summary.
42    ///
43    /// The haystack for each agent is `"{id} {name} {provider} {summary}"`,
44    /// ensuring all four fields specified in Req 32.2 are covered.
45    ///
46    /// ### Filtering
47    ///
48    /// `provider_filter` and `harness_filter` are applied first (intersection),
49    /// then fuzzy matching is applied to the remaining candidates.
50    ///
51    /// ### Empty query
52    ///
53    /// If `query` is empty AND no filters are active, all agent indices are
54    /// returned in original order (score 0).  If filters are active but the
55    /// query is empty, filtered candidates are returned in original order.
56    ///
57    /// ### Return value
58    ///
59    /// Indices into `agents` sorted by match score descending (highest score
60    /// first), with ties broken by original index order (stable sort).
61    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        // Apply provider / harness filters first.
73        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        // Fuzzy match against the concatenated haystack covering all four
108        // fields: id, name, provider, summary (Req 32.2).
109        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                // Haystack covers: id + name + provider + summary (Req 16.2, 32.2).
125                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        // Sort descending by score; ties are broken by ascending original index
139        // (stable sort preserves relative order from the filter step).
140        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    /// Search skills by fuzzy matching against id, name, and summary.
145    ///
146    /// The haystack for each skill is `"{id} {name} {summary}"` — covering the
147    /// three fields applicable to skills (no provider field on Skill).
148    ///
149    /// ### Empty query
150    ///
151    /// Returns all skill indices in original order.
152    ///
153    /// ### Return value
154    ///
155    /// Indices into `skills` sorted by match score descending (stable).
156    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                // Haystack covers: id + name + summary (Req 32.2).
173                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// ─────────────────────────────────────────────────────────────────────────────
195// Tests
196// ─────────────────────────────────────────────────────────────────────────────
197
198#[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    // ── Catalog-based smoke tests ─────────────────────────────────────────────
212
213    #[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        // Results should be a subset of all agents
228        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        // All results should be AWS agents
238        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    // ── Unit tests with synthetic data ────────────────────────────────────────
264
265    /// Build a minimal `Agent` with the exact fields the struct requires.
266    ///
267    /// `provider_str` should be the JSON-serialized provider string (e.g.
268    /// `"\"aws\""` or `"\"accounting\"`).  All non-search-related required
269    /// fields are filled with sensible defaults.
270    fn make_agent(id: &str, name: &str, provider_str: &str, summary: &str) -> Agent {
271        // Agent has #[serde(deny_unknown_fields)], so we must provide exactly
272        // the set of fields declared in the struct (required ones; optional
273        // ones with #[serde(default)] can be omitted).
274        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    /// Build a minimal `Skill` with the exact fields the struct requires.
295    fn make_skill(id: &str, name: &str, summary: &str) -> Skill {
296        // Skill also has #[serde(deny_unknown_fields)].  Only required fields
297        // are provided; all #[serde(default)] fields are omitted.
298        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    // ── Agent: ID field matching ──────────────────────────────────────────────
318
319    #[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    // ── Agent: name field matching ────────────────────────────────────────────
339
340    #[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    // ── Agent: provider field matching ────────────────────────────────────────
360
361    #[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        // Search for "azure" — should match agent-a via the provider field.
369        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        // agent-b (aws) should not appear in an azure-only search
375        // (nucleo may or may not score it; we only assert that index 0 is present).
376    }
377
378    // ── Agent: summary field matching ─────────────────────────────────────────
379
380    #[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    // ── Agent: empty query ────────────────────────────────────────────────────
400
401    #[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        // All indices returned in original order when query is empty.
410        assert_eq!(results, vec![0, 1]);
411    }
412
413    // ── Skill: ID field matching ──────────────────────────────────────────────
414
415    #[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    // ── Skill: name field matching ────────────────────────────────────────────
430
431    #[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    // ── Skill: summary field matching ─────────────────────────────────────────
446
447    #[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    // ── Skill: empty query ────────────────────────────────────────────────────
462
463    #[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    // ── Sort stability ────────────────────────────────────────────────────────
475
476    #[test]
477    fn results_sorted_by_score_descending() {
478        // Create agents where one is a very exact match and one is partial.
479        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        // "sec-audit" should score higher for query "sec audit" than "sec-basic".
495        let results = engine.search_agents("sec audit", &agents, None, None);
496        if results.len() >= 2 {
497            // The first result should be the better match (sec-audit contains
498            // both "sec" and "audit" in close proximity).
499            assert_eq!(results[0], 0, "sec-audit should rank first for 'sec audit'");
500        }
501        // At minimum both must appear since both contain "sec".
502        assert!(results.contains(&0), "sec-audit must be in results");
503    }
504}