Skip to main content

aperture_cli/
search.rs

1//! Command search functionality for discovering API operations
2//!
3//! This module provides search capabilities to help users find relevant
4//! API operations across registered specifications using fuzzy matching
5//! and keyword search.
6
7use 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/// Search result for a command
17#[derive(Debug, Clone)]
18pub struct CommandSearchResult {
19    /// The API context name
20    pub api_context: String,
21    /// The matching command
22    pub command: CachedCommand,
23    /// The command path (e.g., "users get-user")
24    pub command_path: String,
25    /// The relevance score (higher is better)
26    pub score: i64,
27    /// Match highlights
28    pub highlights: Vec<String>,
29}
30
31/// Internal scoring result for a command match
32#[derive(Debug, Default)]
33struct ScoringResult {
34    /// The relevance score (higher is better)
35    score: i64,
36    /// Match highlights
37    highlights: Vec<String>,
38}
39
40/// Command searcher for finding operations across APIs
41pub struct CommandSearcher {
42    /// Fuzzy matcher for similarity scoring
43    matcher: SkimMatcherV2,
44}
45
46impl CommandSearcher {
47    /// Create a new command searcher
48    #[must_use]
49    pub fn new() -> Self {
50        Self {
51            matcher: SkimMatcherV2::default().ignore_case(),
52        }
53    }
54
55    /// Search for commands across specifications
56    ///
57    /// # Arguments
58    /// * `specs` - Map of API context names to cached specifications
59    /// * `query` - The search query (keywords or regex)
60    /// * `api_filter` - Optional API context to limit search
61    ///
62    /// # Returns
63    /// A vector of search results sorted by relevance
64    ///
65    /// # Errors
66    /// Returns an error if regex compilation fails
67    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        // Try to compile as regex first
76        let regex_pattern = Regex::new(query).ok();
77
78        for (api_name, spec) in specs {
79            // Apply API filter if specified - early continue if filter doesn't match
80            if api_filter.is_some_and(|filter| api_name != filter) {
81                continue;
82            }
83
84            for command in &spec.commands {
85                // Score this command against the query
86                let score_result = self.score_command(command, query, regex_pattern.as_ref());
87
88                // Only include results with positive scores
89                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        // Sort by score (highest first)
104        results.sort_by(|a, b| b.score.cmp(&a.score));
105
106        Ok(results)
107    }
108
109    /// Score a single command against a query using regex or fuzzy matching
110    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        // Build searchable text from command attributes, including display overrides and aliases
121        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        // Score based on different matching strategies
139        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    /// Score a command using regex matching
146    fn score_with_regex(regex: &Regex, search_text: &str) -> ScoringResult {
147        // Regex mode - only score if it matches
148        if !regex.is_match(search_text) {
149            return ScoringResult::default();
150        }
151
152        // Dynamic scoring based on match quality for regex
153        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    /// Score a command using fuzzy matching and substring bonuses
166    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        // Fuzzy match on complete search text
177        if let Some(score) = self.matcher.fuzzy_match(search_text, query) {
178            total_score += score;
179        }
180
181        // Bonus score for exact substring matches in various fields
182        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        // Summary requires special handling for Option type
218        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        // Bonus for display name matches (custom command names)
230        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        // Bonus for alias matches
242        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    /// Add bonus score if a field value contains the query string
260    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    /// Find similar commands to a given input
275    ///
276    /// This is used for "did you mean?" suggestions on errors
277    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            // Check fuzzy match score on the effective command path
289            match self.matcher.fuzzy_match(&full_command, input) {
290                Some(score) if score > 0 => suggestions.push((full_command.clone(), score)),
291                _ => {}
292            }
293
294            // Also check the effective subcommand name directly
295            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            // Also check aliases
307            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        // Sort by score and take top results
319        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
332/// Returns the effective command path using display overrides if present.
333///
334/// Uses `command.name` (not `tags.first()`) for the group fallback to stay
335/// consistent with `engine::generator::effective_group_name`.
336fn 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/// Format search results for display
361#[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        // Basic result line
377        lines.push(format!(
378            "{}. aperture api {} {}",
379            number, result.api_context, result.command_path
380        ));
381
382        // Method and path
383        lines.push(format!(
384            "   {} {}",
385            result.command.method.to_uppercase(),
386            result.command.path
387        ));
388
389        // Description if available
390        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        // Show highlights
400        if !result.highlights.is_empty() {
401            lines.push(format!("   Matches: {}", result.highlights.join(", ")));
402        }
403
404        // Show parameters
405        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        // Show request body if present
419        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}