Skip to main content

sgr_agent/openapi/
search.rs

1//! Fuzzy search over API endpoints using nucleo.
2//!
3//! Agent calls `api search "create issue"` → gets top-K matching endpoints.
4
5use super::spec::Endpoint;
6
7/// Search result with score.
8#[derive(Debug, Clone)]
9pub struct SearchResult {
10    pub name: String,
11    pub method: String,
12    pub path: String,
13    pub description: String,
14    pub score: u32,
15}
16
17/// Search endpoints by fuzzy query. Returns top `limit` results sorted by score.
18///
19/// Builds a searchable string for each endpoint:
20/// `name method path description param_names`
21/// Then fuzzy-matches the query against it using nucleo.
22#[cfg(feature = "search")]
23pub fn search_endpoints(endpoints: &[Endpoint], query: &str, limit: usize) -> Vec<SearchResult> {
24    use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
25    use nucleo_matcher::{Config, Matcher, Utf32Str};
26
27    if query.is_empty() || endpoints.is_empty() {
28        return Vec::new();
29    }
30
31    let mut matcher = Matcher::new(Config::DEFAULT.match_paths());
32    let pattern = Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart);
33
34    let mut scored: Vec<(u32, usize)> = Vec::new();
35    let mut buf = Vec::new();
36
37    for (i, ep) in endpoints.iter().enumerate() {
38        let searchable = build_searchable_str(ep);
39        let haystack = Utf32Str::new(&searchable, &mut buf);
40        if let Some(score) = pattern.score(haystack, &mut matcher) {
41            scored.push((score, i));
42        }
43    }
44
45    scored.sort_by(|a, b| b.0.cmp(&a.0));
46    scored.truncate(limit);
47
48    scored
49        .into_iter()
50        .map(|(score, idx)| {
51            let ep = &endpoints[idx];
52            SearchResult {
53                name: ep.name.clone(),
54                method: ep.method.clone(),
55                path: ep.path.clone(),
56                description: ep.description.clone(),
57                score,
58            }
59        })
60        .collect()
61}
62
63/// Simple substring search fallback (no nucleo dependency).
64#[cfg(not(feature = "search"))]
65pub fn search_endpoints(endpoints: &[Endpoint], query: &str, limit: usize) -> Vec<SearchResult> {
66    if query.is_empty() || endpoints.is_empty() {
67        return Vec::new();
68    }
69
70    let query_lower = query.to_lowercase();
71    let query_words: Vec<&str> = query_lower.split_whitespace().collect();
72    let mut results: Vec<SearchResult> = Vec::new();
73
74    for ep in endpoints {
75        let searchable = build_searchable_str(ep).to_lowercase();
76        // Match if ALL query words appear in the searchable string
77        if query_words.iter().all(|w| searchable.contains(w)) {
78            results.push(SearchResult {
79                name: ep.name.clone(),
80                method: ep.method.clone(),
81                path: ep.path.clone(),
82                description: ep.description.clone(),
83                score: 100,
84            });
85            if results.len() >= limit {
86                break;
87            }
88        }
89    }
90
91    results
92}
93
94/// Build a searchable string from endpoint fields.
95fn build_searchable_str(ep: &Endpoint) -> String {
96    let mut parts = vec![
97        ep.name.replace('_', " "),
98        ep.method.clone(),
99        ep.path.replace('/', " ").replace(['{', '}'], ""),
100    ];
101    if !ep.description.is_empty() {
102        parts.push(ep.description.clone());
103    }
104    for p in &ep.params {
105        parts.push(p.name.clone());
106        if !p.description.is_empty() {
107            parts.push(p.description.clone());
108        }
109    }
110    parts.join(" ")
111}
112
113/// Format search results for display to the agent.
114pub fn format_results(results: &[SearchResult]) -> String {
115    if results.is_empty() {
116        return "No endpoints found.".to_string();
117    }
118
119    let mut out = String::new();
120    for r in results {
121        out.push_str(&format!(
122            "  {} {} {} — {}\n",
123            r.method, r.name, r.path, r.description
124        ));
125    }
126    out
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use crate::openapi::spec::{Endpoint, Param, ParamLocation, parse_spec};
133    use serde_json::json;
134
135    fn test_endpoints() -> Vec<Endpoint> {
136        let spec = json!({
137            "paths": {
138                "/users": {
139                    "get": { "summary": "List all users", "parameters": [] },
140                    "post": { "summary": "Create a new user", "parameters": [] }
141                },
142                "/repos/{owner}/{repo}/issues": {
143                    "get": {
144                        "summary": "List repository issues",
145                        "parameters": [
146                            { "name": "owner", "in": "path", "required": true, "schema": { "type": "string" } },
147                            { "name": "repo", "in": "path", "required": true, "schema": { "type": "string" } },
148                            { "name": "state", "in": "query", "schema": { "type": "string" }, "description": "Filter by state" }
149                        ]
150                    },
151                    "post": {
152                        "summary": "Create an issue",
153                        "parameters": [
154                            { "name": "owner", "in": "path", "required": true, "schema": { "type": "string" } },
155                            { "name": "repo", "in": "path", "required": true, "schema": { "type": "string" } }
156                        ]
157                    }
158                },
159                "/repos/{owner}/{repo}/pulls": {
160                    "get": { "summary": "List pull requests", "parameters": [] }
161                }
162            }
163        });
164        parse_spec(&spec)
165    }
166
167    #[test]
168    fn search_finds_relevant() {
169        let eps = test_endpoints();
170        let results = search_endpoints(&eps, "create issue", 5);
171        assert!(!results.is_empty());
172        // "Create an issue" should rank high
173        assert!(results[0].description.contains("issue") || results[0].name.contains("issue"));
174    }
175
176    #[test]
177    fn search_empty_query() {
178        let eps = test_endpoints();
179        let results = search_endpoints(&eps, "", 5);
180        assert!(results.is_empty());
181    }
182
183    #[test]
184    fn search_respects_limit() {
185        let eps = test_endpoints();
186        let results = search_endpoints(&eps, "repo", 2);
187        assert!(results.len() <= 2);
188    }
189
190    #[test]
191    fn format_results_empty() {
192        assert_eq!(format_results(&[]), "No endpoints found.");
193    }
194
195    #[test]
196    fn format_results_shows_method_and_path() {
197        let results = vec![SearchResult {
198            name: "users_get".into(),
199            method: "GET".into(),
200            path: "/users".into(),
201            description: "List users".into(),
202            score: 100,
203        }];
204        let out = format_results(&results);
205        assert!(out.contains("GET"));
206        assert!(out.contains("/users"));
207        assert!(out.contains("List users"));
208    }
209
210    #[test]
211    fn searchable_string_includes_all_fields() {
212        let ep = Endpoint {
213            name: "users_get".into(),
214            method: "GET".into(),
215            path: "/users".into(),
216            description: "List all users".into(),
217            params: vec![Param {
218                name: "page".into(),
219                location: ParamLocation::Query,
220                required: false,
221                param_type: "integer".into(),
222                description: "Page number".into(),
223            }],
224        };
225        let s = build_searchable_str(&ep);
226        assert!(s.contains("users"));
227        assert!(s.contains("GET"));
228        assert!(s.contains("List all users"));
229        assert!(s.contains("page"));
230        assert!(s.contains("Page number"));
231    }
232}