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 operation_id_kebab = to_kebab_case(&command.operation_id);
91 let tag = command.tags.first().map_or_else(
92 || constants::DEFAULT_GROUP.to_string(),
93 |t| to_kebab_case(t),
94 );
95 let command_path = format!("{tag} {operation_id_kebab}");
96
97 results.push(CommandSearchResult {
98 api_context: api_name.clone(),
99 command: command.clone(),
100 command_path,
101 score: score_result.score,
102 highlights: score_result.highlights,
103 });
104 }
105 }
106 }
107
108 results.sort_by(|a, b| b.score.cmp(&a.score));
110
111 Ok(results)
112 }
113
114 fn score_command(
116 &self,
117 command: &CachedCommand,
118 query: &str,
119 regex_pattern: Option<&Regex>,
120 ) -> ScoringResult {
121 let operation_id_kebab = to_kebab_case(&command.operation_id);
122 let summary = command.summary.as_deref().unwrap_or("");
123 let description = command.description.as_deref().unwrap_or("");
124
125 let search_text = format!(
127 "{operation_id_kebab} {} {} {} {summary} {description}",
128 command.operation_id, command.method, command.path
129 );
130
131 regex_pattern.map_or_else(
133 || self.score_with_fuzzy_match(command, query, &search_text, &operation_id_kebab),
134 |regex| Self::score_with_regex(regex, &search_text),
135 )
136 }
137
138 fn score_with_regex(regex: &Regex, search_text: &str) -> ScoringResult {
140 if !regex.is_match(search_text) {
142 return ScoringResult::default();
143 }
144
145 let base_score = 90;
147 let query_len = regex.as_str().len();
148 #[allow(clippy::cast_possible_wrap)]
149 let match_specificity_bonus = query_len.min(10) as i64;
150 let total_score = base_score + match_specificity_bonus;
151
152 ScoringResult {
153 score: total_score,
154 highlights: vec![format!("Regex match: {}", regex.as_str())],
155 }
156 }
157
158 fn score_with_fuzzy_match(
160 &self,
161 command: &CachedCommand,
162 query: &str,
163 search_text: &str,
164 operation_id_kebab: &str,
165 ) -> ScoringResult {
166 let mut highlights = Vec::new();
167 let mut total_score = 0i64;
168
169 if let Some(score) = self.matcher.fuzzy_match(search_text, query) {
171 total_score += score;
172 }
173
174 let query_lower = query.to_lowercase();
176
177 Self::add_field_bonus(
178 &query_lower,
179 operation_id_kebab,
180 "Operation",
181 50,
182 &mut total_score,
183 &mut highlights,
184 );
185 Self::add_field_bonus(
186 &query_lower,
187 &command.operation_id,
188 "Operation",
189 50,
190 &mut total_score,
191 &mut highlights,
192 );
193 Self::add_field_bonus(
194 &query_lower,
195 &command.method,
196 "Method",
197 30,
198 &mut total_score,
199 &mut highlights,
200 );
201 Self::add_field_bonus(
202 &query_lower,
203 &command.path,
204 "Path",
205 20,
206 &mut total_score,
207 &mut highlights,
208 );
209
210 if let Some(summary) = &command.summary {
212 Self::add_field_bonus(
213 &query_lower,
214 summary,
215 "Summary",
216 15,
217 &mut total_score,
218 &mut highlights,
219 );
220 }
221
222 ScoringResult {
223 score: total_score,
224 highlights,
225 }
226 }
227
228 fn add_field_bonus(
230 query_lower: &str,
231 field_value: &str,
232 field_label: &str,
233 score: i64,
234 total_score: &mut i64,
235 highlights: &mut Vec<String>,
236 ) {
237 if field_value.to_lowercase().contains(query_lower) {
238 *total_score += score;
239 highlights.push(format!("{field_label}: {field_value}"));
240 }
241 }
242
243 pub fn find_similar_commands(
247 &self,
248 spec: &CachedSpec,
249 input: &str,
250 max_results: usize,
251 ) -> Vec<(String, i64)> {
252 let mut suggestions = Vec::new();
253
254 for command in &spec.commands {
255 let operation_id_kebab = to_kebab_case(&command.operation_id);
256 let tag = command.tags.first().map_or_else(
257 || constants::DEFAULT_GROUP.to_string(),
258 |t| to_kebab_case(t),
259 );
260 let full_command = format!("{tag} {operation_id_kebab}");
261
262 match self.matcher.fuzzy_match(&full_command, input) {
264 Some(score) if score > 0 => suggestions.push((full_command.clone(), score)),
265 _ => {}
266 }
267
268 match self.matcher.fuzzy_match(&operation_id_kebab, input) {
270 Some(score) if score > 0 => suggestions.push((full_command.clone(), score + 10)), _ => {}
272 }
273 }
274
275 suggestions.sort_by(|a, b| b.1.cmp(&a.1));
277 suggestions.truncate(max_results);
278
279 suggestions
280 }
281}
282
283impl Default for CommandSearcher {
284 fn default() -> Self {
285 Self::new()
286 }
287}
288
289#[must_use]
291pub fn format_search_results(results: &[CommandSearchResult], verbose: bool) -> Vec<String> {
292 let mut lines = Vec::new();
293
294 if results.is_empty() {
295 lines.push("No matching operations found.".to_string());
296 return lines;
297 }
298
299 lines.push(format!("Found {} matching operation(s):", results.len()));
300 lines.push(String::new());
301
302 for (idx, result) in results.iter().enumerate() {
303 let number = idx + 1;
304
305 lines.push(format!(
307 "{}. aperture api {} {}",
308 number, result.api_context, result.command_path
309 ));
310
311 lines.push(format!(
313 " {} {}",
314 result.command.method.to_uppercase(),
315 result.command.path
316 ));
317
318 if let Some(ref summary) = result.command.summary {
320 lines.push(format!(" {summary}"));
321 }
322
323 if !verbose {
324 lines.push(String::new());
325 continue;
326 }
327
328 if !result.highlights.is_empty() {
330 lines.push(format!(" Matches: {}", result.highlights.join(", ")));
331 }
332
333 if !result.command.parameters.is_empty() {
335 let params: Vec<String> = result
336 .command
337 .parameters
338 .iter()
339 .map(|p| {
340 let required = if p.required { "*" } else { "" };
341 format!("--{}{}", p.name, required)
342 })
343 .collect();
344 lines.push(format!(" Parameters: {}", params.join(" ")));
345 }
346
347 if result.command.request_body.is_some() {
349 lines.push(" Request body: JSON required".to_string());
350 }
351
352 lines.push(String::new());
353 }
354
355 lines
356}