1use super::spec::Endpoint;
6
7#[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#[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#[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 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
85fn 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
104pub 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 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}