Skip to main content

sgr_agent/openapi/
search.rs

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