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 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#[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 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
94fn 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
113pub 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 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}