1use crate::cache::models::{CachedCommand, CachedSpec};
8use crate::constants;
9use crate::error::Error;
10use crate::utils::to_kebab_case;
11use fuzzy_matcher::skim::SkimMatcherV2;
12use fuzzy_matcher::FuzzyMatcher;
13use regex::Regex;
14use std::collections::BTreeMap;
15
16#[derive(Debug, Clone)]
18pub struct CommandSearchResult {
19 pub api_context: String,
21 pub command: CachedCommand,
23 pub command_path: String,
25 pub score: i64,
27 pub highlights: Vec<String>,
29}
30
31#[derive(Debug, Default)]
33struct ScoringResult {
34 score: i64,
36 highlights: Vec<String>,
38}
39
40pub struct CommandSearcher {
42 matcher: SkimMatcherV2,
44}
45
46impl CommandSearcher {
47 #[must_use]
49 pub fn new() -> Self {
50 Self {
51 matcher: SkimMatcherV2::default().ignore_case(),
52 }
53 }
54
55 pub fn search(
68 &self,
69 specs: &BTreeMap<String, CachedSpec>,
70 query: &str,
71 api_filter: Option<&str>,
72 ) -> Result<Vec<CommandSearchResult>, Error> {
73 let mut results = Vec::new();
74
75 let regex_pattern = Regex::new(query).ok();
77
78 for (api_name, spec) in specs {
79 if api_filter.is_some_and(|filter| api_name != filter) {
81 continue;
82 }
83
84 for command in &spec.commands {
85 let score_result = self.score_command(command, query, regex_pattern.as_ref());
87
88 if score_result.score > 0 {
90 let command_path = effective_command_path(command);
91
92 results.push(CommandSearchResult {
93 api_context: api_name.clone(),
94 command: command.clone(),
95 command_path,
96 score: score_result.score,
97 highlights: score_result.highlights,
98 });
99 }
100 }
101 }
102
103 results.sort_by(|a, b| b.score.cmp(&a.score));
105
106 Ok(results)
107 }
108
109 fn score_command(
111 &self,
112 command: &CachedCommand,
113 query: &str,
114 regex_pattern: Option<&Regex>,
115 ) -> ScoringResult {
116 let operation_id_kebab = to_kebab_case(&command.operation_id);
117 let summary = command.summary.as_deref().unwrap_or("");
118 let description = command.description.as_deref().unwrap_or("");
119
120 let display_name = command
122 .display_name
123 .as_deref()
124 .map(to_kebab_case)
125 .unwrap_or_default();
126 let display_group = command
127 .display_group
128 .as_deref()
129 .map(to_kebab_case)
130 .unwrap_or_default();
131 let aliases_text = command.aliases.join(" ");
132
133 let search_text = format!(
134 "{operation_id_kebab} {} {} {} {summary} {description} {display_name} {display_group} {aliases_text}",
135 command.operation_id, command.method, command.path
136 );
137
138 regex_pattern.map_or_else(
140 || self.score_with_fuzzy_match(command, query, &search_text, &operation_id_kebab),
141 |regex| Self::score_with_regex(regex, &search_text),
142 )
143 }
144
145 fn score_with_regex(regex: &Regex, search_text: &str) -> ScoringResult {
147 if !regex.is_match(search_text) {
149 return ScoringResult::default();
150 }
151
152 let base_score = 90;
154 let query_len = regex.as_str().len();
155 #[allow(clippy::cast_possible_wrap)]
156 let match_specificity_bonus = query_len.min(10) as i64;
157 let total_score = base_score + match_specificity_bonus;
158
159 ScoringResult {
160 score: total_score,
161 highlights: vec![format!("Regex match: {}", regex.as_str())],
162 }
163 }
164
165 fn score_with_fuzzy_match(
167 &self,
168 command: &CachedCommand,
169 query: &str,
170 search_text: &str,
171 operation_id_kebab: &str,
172 ) -> ScoringResult {
173 let mut highlights = Vec::new();
174 let mut total_score = 0i64;
175
176 if let Some(score) = self.matcher.fuzzy_match(search_text, query) {
178 total_score += score;
179 }
180
181 let query_lower = query.to_lowercase();
183
184 Self::add_field_bonus(
185 &query_lower,
186 operation_id_kebab,
187 "Operation",
188 50,
189 &mut total_score,
190 &mut highlights,
191 );
192 Self::add_field_bonus(
193 &query_lower,
194 &command.operation_id,
195 "Operation",
196 50,
197 &mut total_score,
198 &mut highlights,
199 );
200 Self::add_field_bonus(
201 &query_lower,
202 &command.method,
203 "Method",
204 30,
205 &mut total_score,
206 &mut highlights,
207 );
208 Self::add_field_bonus(
209 &query_lower,
210 &command.path,
211 "Path",
212 20,
213 &mut total_score,
214 &mut highlights,
215 );
216
217 if let Some(summary) = &command.summary {
219 Self::add_field_bonus(
220 &query_lower,
221 summary,
222 "Summary",
223 15,
224 &mut total_score,
225 &mut highlights,
226 );
227 }
228
229 if let Some(display_name) = &command.display_name {
231 Self::add_field_bonus(
232 &query_lower,
233 display_name,
234 "Display name",
235 50,
236 &mut total_score,
237 &mut highlights,
238 );
239 }
240
241 for alias in &command.aliases {
243 Self::add_field_bonus(
244 &query_lower,
245 alias,
246 "Alias",
247 45,
248 &mut total_score,
249 &mut highlights,
250 );
251 }
252
253 ScoringResult {
254 score: total_score,
255 highlights,
256 }
257 }
258
259 fn add_field_bonus(
261 query_lower: &str,
262 field_value: &str,
263 field_label: &str,
264 score: i64,
265 total_score: &mut i64,
266 highlights: &mut Vec<String>,
267 ) {
268 if field_value.to_lowercase().contains(query_lower) {
269 *total_score += score;
270 highlights.push(format!("{field_label}: {field_value}"));
271 }
272 }
273
274 pub fn find_similar_commands(
278 &self,
279 spec: &CachedSpec,
280 input: &str,
281 max_results: usize,
282 ) -> Vec<(String, i64)> {
283 let mut suggestions = Vec::new();
284
285 for command in &spec.commands {
286 let full_command = effective_command_path(command);
287
288 match self.matcher.fuzzy_match(&full_command, input) {
290 Some(score) if score > 0 => suggestions.push((full_command.clone(), score)),
291 _ => {}
292 }
293
294 let effective_name = command
296 .display_name
297 .as_deref()
298 .map_or_else(|| to_kebab_case(&command.operation_id), to_kebab_case);
299 match self.matcher.fuzzy_match(&effective_name, input) {
300 Some(score) if score > 0 => {
301 suggestions.push((full_command.clone(), score + 10));
302 }
303 _ => {}
304 }
305
306 for alias in &command.aliases {
308 let alias_kebab = to_kebab_case(alias);
309 match self.matcher.fuzzy_match(&alias_kebab, input) {
310 Some(score) if score > 0 => {
311 suggestions.push((full_command.clone(), score + 5));
312 }
313 _ => {}
314 }
315 }
316 }
317
318 suggestions.sort_by(|a, b| b.1.cmp(&a.1));
320 suggestions.truncate(max_results);
321
322 suggestions
323 }
324}
325
326impl Default for CommandSearcher {
327 fn default() -> Self {
328 Self::new()
329 }
330}
331
332fn effective_command_path(command: &CachedCommand) -> String {
337 let group = command.display_group.as_ref().map_or_else(
338 || {
339 if command.name.is_empty() {
340 constants::DEFAULT_GROUP.to_string()
341 } else {
342 to_kebab_case(&command.name)
343 }
344 },
345 |g| to_kebab_case(g),
346 );
347 let name = command.display_name.as_ref().map_or_else(
348 || {
349 if command.operation_id.is_empty() {
350 command.method.to_lowercase()
351 } else {
352 to_kebab_case(&command.operation_id)
353 }
354 },
355 |n| to_kebab_case(n),
356 );
357 format!("{group} {name}")
358}
359
360#[must_use]
362pub fn format_search_results(results: &[CommandSearchResult], verbose: bool) -> Vec<String> {
363 let mut lines = Vec::new();
364
365 if results.is_empty() {
366 lines.push("No matching operations found.".to_string());
367 return lines;
368 }
369
370 lines.push(format!("Found {} matching operation(s):", results.len()));
371 lines.push(String::new());
372
373 for (idx, result) in results.iter().enumerate() {
374 let number = idx + 1;
375
376 lines.push(format!(
378 "{}. aperture api {} {}",
379 number, result.api_context, result.command_path
380 ));
381
382 lines.push(format!(
384 " {} {}",
385 result.command.method.to_uppercase(),
386 result.command.path
387 ));
388
389 if let Some(ref summary) = result.command.summary {
391 lines.push(format!(" {summary}"));
392 }
393
394 if !verbose {
395 lines.push(String::new());
396 continue;
397 }
398
399 if !result.highlights.is_empty() {
401 lines.push(format!(" Matches: {}", result.highlights.join(", ")));
402 }
403
404 if !result.command.parameters.is_empty() {
406 let params: Vec<String> = result
407 .command
408 .parameters
409 .iter()
410 .map(|p| {
411 let required = if p.required { "*" } else { "" };
412 format!("--{}{}", p.name, required)
413 })
414 .collect();
415 lines.push(format!(" Parameters: {}", params.join(" ")));
416 }
417
418 if result.command.request_body.is_some() {
420 lines.push(" Request body: JSON required".to_string());
421 }
422
423 lines.push(String::new());
424 }
425
426 lines
427}