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
31pub struct CommandSearcher {
33 matcher: SkimMatcherV2,
35}
36
37impl CommandSearcher {
38 #[must_use]
40 pub fn new() -> Self {
41 Self {
42 matcher: SkimMatcherV2::default().ignore_case(),
43 }
44 }
45
46 pub fn search(
59 &self,
60 specs: &BTreeMap<String, CachedSpec>,
61 query: &str,
62 api_filter: Option<&str>,
63 ) -> Result<Vec<CommandSearchResult>, Error> {
64 let mut results = Vec::new();
65
66 let regex_pattern = Regex::new(query).ok();
68
69 for (api_name, spec) in specs {
70 if let Some(filter) = api_filter {
72 if api_name != filter {
73 continue;
74 }
75 }
76
77 for command in &spec.commands {
78 let mut highlights = Vec::new();
79 let mut total_score = 0i64;
80
81 let operation_id_kebab = to_kebab_case(&command.operation_id);
83 let summary = command.summary.as_deref().unwrap_or("");
84 let description = command.description.as_deref().unwrap_or("");
85
86 let search_text = format!(
88 "{operation_id_kebab} {} {} {} {summary} {description}",
89 command.operation_id, command.method, command.path
90 );
91
92 if let Some(ref regex) = regex_pattern {
94 if regex.is_match(&search_text) {
95 let base_score = 90;
97 let query_len = regex.as_str().len();
98 #[allow(clippy::cast_possible_wrap)]
99 let match_specificity_bonus = query_len.min(10) as i64;
100 total_score = base_score + match_specificity_bonus;
101 highlights.push(format!("Regex match: {}", regex.as_str()));
102 }
103 } else {
104 if let Some(score) = self.matcher.fuzzy_match(&search_text, query) {
106 total_score += score;
107 }
108
109 let query_lower = query.to_lowercase();
111 if operation_id_kebab.to_lowercase().contains(&query_lower) {
112 total_score += 50;
113 highlights.push(format!("Operation: {operation_id_kebab}"));
114 }
115 if command.operation_id.to_lowercase().contains(&query_lower) {
117 total_score += 50;
118 highlights.push(format!("Operation: {}", command.operation_id));
119 }
120 if command.method.to_lowercase().contains(&query_lower) {
121 total_score += 30;
122 highlights.push(format!("Method: {}", command.method));
123 }
124 if command.path.to_lowercase().contains(&query_lower) {
125 total_score += 20;
126 highlights.push(format!("Path: {}", command.path));
127 }
128 if let Some(ref summary) = command.summary {
129 if summary.to_lowercase().contains(&query_lower) {
130 total_score += 15;
131 highlights.push("Summary match".to_string());
132 }
133 }
134 }
135
136 if total_score > 0 {
138 let tag = command.tags.first().map_or_else(
139 || constants::DEFAULT_GROUP.to_string(),
140 |t| to_kebab_case(t),
141 );
142 let command_path = format!("{tag} {operation_id_kebab}");
143
144 results.push(CommandSearchResult {
145 api_context: api_name.clone(),
146 command: command.clone(),
147 command_path,
148 score: total_score,
149 highlights,
150 });
151 }
152 }
153 }
154
155 results.sort_by(|a, b| b.score.cmp(&a.score));
157
158 Ok(results)
159 }
160
161 pub fn find_similar_commands(
165 &self,
166 spec: &CachedSpec,
167 input: &str,
168 max_results: usize,
169 ) -> Vec<(String, i64)> {
170 let mut suggestions = Vec::new();
171
172 for command in &spec.commands {
173 let operation_id_kebab = to_kebab_case(&command.operation_id);
174 let tag = command.tags.first().map_or_else(
175 || constants::DEFAULT_GROUP.to_string(),
176 |t| to_kebab_case(t),
177 );
178 let full_command = format!("{tag} {operation_id_kebab}");
179
180 if let Some(score) = self.matcher.fuzzy_match(&full_command, input) {
182 if score > 0 {
183 suggestions.push((full_command.clone(), score));
184 }
185 }
186
187 if let Some(score) = self.matcher.fuzzy_match(&operation_id_kebab, input) {
189 if score > 0 {
190 suggestions.push((full_command.clone(), score + 10)); }
192 }
193 }
194
195 suggestions.sort_by(|a, b| b.1.cmp(&a.1));
197 suggestions.truncate(max_results);
198
199 suggestions
200 }
201}
202
203impl Default for CommandSearcher {
204 fn default() -> Self {
205 Self::new()
206 }
207}
208
209#[must_use]
211pub fn format_search_results(results: &[CommandSearchResult], verbose: bool) -> Vec<String> {
212 let mut lines = Vec::new();
213
214 if results.is_empty() {
215 lines.push("No matching operations found.".to_string());
216 return lines;
217 }
218
219 lines.push(format!("Found {} matching operation(s):", results.len()));
220 lines.push(String::new());
221
222 for (idx, result) in results.iter().enumerate() {
223 let number = idx + 1;
224
225 lines.push(format!(
227 "{}. aperture api {} {}",
228 number, result.api_context, result.command_path
229 ));
230
231 lines.push(format!(
233 " {} {}",
234 result.command.method.to_uppercase(),
235 result.command.path
236 ));
237
238 if let Some(ref summary) = result.command.summary {
240 lines.push(format!(" {summary}"));
241 }
242
243 if verbose {
244 if !result.highlights.is_empty() {
246 lines.push(format!(" Matches: {}", result.highlights.join(", ")));
247 }
248
249 if !result.command.parameters.is_empty() {
251 let params: Vec<String> = result
252 .command
253 .parameters
254 .iter()
255 .map(|p| {
256 let required = if p.required { "*" } else { "" };
257 format!("--{}{}", p.name, required)
258 })
259 .collect();
260 lines.push(format!(" Parameters: {}", params.join(" ")));
261 }
262
263 if result.command.request_body.is_some() {
265 lines.push(" Request body: JSON required".to_string());
266 }
267 }
268
269 lines.push(String::new());
270 }
271
272 lines
273}