1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
//! Command search functionality for discovering API operations
//!
//! This module provides search capabilities to help users find relevant
//! API operations across registered specifications using fuzzy matching
//! and keyword search.
use crate::cache::models::{CachedCommand, CachedSpec};
use crate::constants;
use crate::error::Error;
use crate::utils::to_kebab_case;
use fuzzy_matcher::skim::SkimMatcherV2;
use fuzzy_matcher::FuzzyMatcher;
use regex::Regex;
use std::collections::BTreeMap;
/// Search result for a command
#[derive(Debug, Clone)]
pub struct CommandSearchResult {
/// The API context name
pub api_context: String,
/// The matching command
pub command: CachedCommand,
/// The command path (e.g., "users get-user")
pub command_path: String,
/// The relevance score (higher is better)
pub score: i64,
/// Match highlights
pub highlights: Vec<String>,
}
/// Internal scoring result for a command match
#[derive(Debug, Default)]
struct ScoringResult {
/// The relevance score (higher is better)
score: i64,
/// Match highlights
highlights: Vec<String>,
}
/// Command searcher for finding operations across APIs
pub struct CommandSearcher {
/// Fuzzy matcher for similarity scoring
matcher: SkimMatcherV2,
}
impl CommandSearcher {
/// Create a new command searcher
#[must_use]
pub fn new() -> Self {
Self {
matcher: SkimMatcherV2::default().ignore_case(),
}
}
/// Search for commands across specifications
///
/// # Arguments
/// * `specs` - Map of API context names to cached specifications
/// * `query` - The search query (keywords or regex)
/// * `api_filter` - Optional API context to limit search
///
/// # Returns
/// A vector of search results sorted by relevance
///
/// # Errors
/// Returns an error if regex compilation fails
pub fn search(
&self,
specs: &BTreeMap<String, CachedSpec>,
query: &str,
api_filter: Option<&str>,
) -> Result<Vec<CommandSearchResult>, Error> {
let mut results = Vec::new();
// Try to compile as regex first
let regex_pattern = Regex::new(query).ok();
for (api_name, spec) in specs {
// Apply API filter if specified - early continue if filter doesn't match
if api_filter.is_some_and(|filter| api_name != filter) {
continue;
}
for command in &spec.commands {
// Score this command against the query
let score_result = self.score_command(command, query, regex_pattern.as_ref());
// Only include results with positive scores
if score_result.score > 0 {
let command_path = effective_command_path(command);
results.push(CommandSearchResult {
api_context: api_name.clone(),
command: command.clone(),
command_path,
score: score_result.score,
highlights: score_result.highlights,
});
}
}
}
// Sort by score (highest first)
results.sort_by(|a, b| b.score.cmp(&a.score));
Ok(results)
}
/// Score a single command against a query using regex or fuzzy matching
fn score_command(
&self,
command: &CachedCommand,
query: &str,
regex_pattern: Option<&Regex>,
) -> ScoringResult {
let operation_id_kebab = to_kebab_case(&command.operation_id);
let summary = command.summary.as_deref().unwrap_or("");
let description = command.description.as_deref().unwrap_or("");
// Build searchable text from command attributes, including display overrides and aliases
let display_name = command
.display_name
.as_deref()
.map(to_kebab_case)
.unwrap_or_default();
let display_group = command
.display_group
.as_deref()
.map(to_kebab_case)
.unwrap_or_default();
let aliases_text = command.aliases.join(" ");
let search_text = format!(
"{operation_id_kebab} {} {} {} {summary} {description} {display_name} {display_group} {aliases_text}",
command.operation_id, command.method, command.path
);
// Score based on different matching strategies
regex_pattern.map_or_else(
|| self.score_with_fuzzy_match(command, query, &search_text, &operation_id_kebab),
|regex| Self::score_with_regex(regex, &search_text),
)
}
/// Score a command using regex matching
fn score_with_regex(regex: &Regex, search_text: &str) -> ScoringResult {
// Regex mode - only score if it matches
if !regex.is_match(search_text) {
return ScoringResult::default();
}
// Dynamic scoring based on match quality for regex
let base_score = 90;
let query_len = regex.as_str().len();
#[allow(clippy::cast_possible_wrap)]
let match_specificity_bonus = query_len.min(10) as i64;
let total_score = base_score + match_specificity_bonus;
ScoringResult {
score: total_score,
highlights: vec![format!("Regex match: {}", regex.as_str())],
}
}
/// Score a command using fuzzy matching and substring bonuses
fn score_with_fuzzy_match(
&self,
command: &CachedCommand,
query: &str,
search_text: &str,
operation_id_kebab: &str,
) -> ScoringResult {
let mut highlights = Vec::new();
let mut total_score = 0i64;
// Fuzzy match on complete search text
if let Some(score) = self.matcher.fuzzy_match(search_text, query) {
total_score += score;
}
// Bonus score for exact substring matches in various fields
let query_lower = query.to_lowercase();
Self::add_field_bonus(
&query_lower,
operation_id_kebab,
"Operation",
50,
&mut total_score,
&mut highlights,
);
Self::add_field_bonus(
&query_lower,
&command.operation_id,
"Operation",
50,
&mut total_score,
&mut highlights,
);
Self::add_field_bonus(
&query_lower,
&command.method,
"Method",
30,
&mut total_score,
&mut highlights,
);
Self::add_field_bonus(
&query_lower,
&command.path,
"Path",
20,
&mut total_score,
&mut highlights,
);
// Summary requires special handling for Option type
if let Some(summary) = &command.summary {
Self::add_field_bonus(
&query_lower,
summary,
"Summary",
15,
&mut total_score,
&mut highlights,
);
}
// Bonus for display name matches (custom command names)
if let Some(display_name) = &command.display_name {
Self::add_field_bonus(
&query_lower,
display_name,
"Display name",
50,
&mut total_score,
&mut highlights,
);
}
// Bonus for alias matches
for alias in &command.aliases {
Self::add_field_bonus(
&query_lower,
alias,
"Alias",
45,
&mut total_score,
&mut highlights,
);
}
ScoringResult {
score: total_score,
highlights,
}
}
/// Add bonus score if a field value contains the query string
fn add_field_bonus(
query_lower: &str,
field_value: &str,
field_label: &str,
score: i64,
total_score: &mut i64,
highlights: &mut Vec<String>,
) {
if field_value.to_lowercase().contains(query_lower) {
*total_score += score;
highlights.push(format!("{field_label}: {field_value}"));
}
}
/// Find similar commands to a given input
///
/// This is used for "did you mean?" suggestions on errors
pub fn find_similar_commands(
&self,
spec: &CachedSpec,
input: &str,
max_results: usize,
) -> Vec<(String, i64)> {
let mut suggestions = Vec::new();
for command in &spec.commands {
let full_command = effective_command_path(command);
// Check fuzzy match score on the effective command path
match self.matcher.fuzzy_match(&full_command, input) {
Some(score) if score > 0 => suggestions.push((full_command.clone(), score)),
_ => {}
}
// Also check the effective subcommand name directly
let effective_name = command
.display_name
.as_deref()
.map_or_else(|| to_kebab_case(&command.operation_id), to_kebab_case);
match self.matcher.fuzzy_match(&effective_name, input) {
Some(score) if score > 0 => {
suggestions.push((full_command.clone(), score + 10));
}
_ => {}
}
// Also check aliases
for alias in &command.aliases {
let alias_kebab = to_kebab_case(alias);
match self.matcher.fuzzy_match(&alias_kebab, input) {
Some(score) if score > 0 => {
suggestions.push((full_command.clone(), score + 5));
}
_ => {}
}
}
}
// Sort by score and take top results
suggestions.sort_by(|a, b| b.1.cmp(&a.1));
suggestions.truncate(max_results);
suggestions
}
}
impl Default for CommandSearcher {
fn default() -> Self {
Self::new()
}
}
/// Returns the effective command path using display overrides if present.
///
/// Uses `command.name` (not `tags.first()`) for the group fallback to stay
/// consistent with `engine::generator::effective_group_name`.
fn effective_command_path(command: &CachedCommand) -> String {
let group = command.display_group.as_ref().map_or_else(
|| {
if command.name.is_empty() {
constants::DEFAULT_GROUP.to_string()
} else {
to_kebab_case(&command.name)
}
},
|g| to_kebab_case(g),
);
let name = command.display_name.as_ref().map_or_else(
|| {
if command.operation_id.is_empty() {
command.method.to_lowercase()
} else {
to_kebab_case(&command.operation_id)
}
},
|n| to_kebab_case(n),
);
format!("{group} {name}")
}
/// Format search results for display
#[must_use]
pub fn format_search_results(results: &[CommandSearchResult], verbose: bool) -> Vec<String> {
let mut lines = Vec::new();
if results.is_empty() {
lines.push("No matching operations found.".to_string());
return lines;
}
lines.push(format!("Found {} matching operation(s):", results.len()));
lines.push(String::new());
for (idx, result) in results.iter().enumerate() {
let number = idx + 1;
// Basic result line
lines.push(format!(
"{}. aperture api {} {}",
number, result.api_context, result.command_path
));
// Method and path
lines.push(format!(
" {} {}",
result.command.method.to_uppercase(),
result.command.path
));
// Description if available
if let Some(ref summary) = result.command.summary {
lines.push(format!(" {summary}"));
}
if !verbose {
lines.push(String::new());
continue;
}
// Show highlights
if !result.highlights.is_empty() {
lines.push(format!(" Matches: {}", result.highlights.join(", ")));
}
// Show parameters
if !result.command.parameters.is_empty() {
let params: Vec<String> = result
.command
.parameters
.iter()
.map(|p| {
let required = if p.required { "*" } else { "" };
format!("--{}{}", p.name, required)
})
.collect();
lines.push(format!(" Parameters: {}", params.join(" ")));
}
// Show request body if present
if result.command.request_body.is_some() {
lines.push(" Request body: JSON required".to_string());
}
lines.push(String::new());
}
lines
}