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 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        // Sort by score (highest first)
109        results.sort_by(|a, b| b.score.cmp(&a.score));
110
111        Ok(results)
112    }
113
114    /// Score a single command against a query using regex or fuzzy matching
115    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        // Build searchable text from command attributes
126        let search_text = format!(
127            "{operation_id_kebab} {} {} {} {summary} {description}",
128            command.operation_id, command.method, command.path
129        );
130
131        // Score based on different matching strategies
132        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    /// Score a command using regex matching
139    fn score_with_regex(regex: &Regex, search_text: &str) -> ScoringResult {
140        // Regex mode - only score if it matches
141        if !regex.is_match(search_text) {
142            return ScoringResult::default();
143        }
144
145        // Dynamic scoring based on match quality for regex
146        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    /// Score a command using fuzzy matching and substring bonuses
159    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        // Fuzzy match on complete search text
170        if let Some(score) = self.matcher.fuzzy_match(search_text, query) {
171            total_score += score;
172        }
173
174        // Bonus score for exact substring matches in various fields
175        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        // Summary requires special handling for Option type
211        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    /// Add bonus score if a field value contains the query string
229    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    /// Find similar commands to a given input
244    ///
245    /// This is used for "did you mean?" suggestions on errors
246    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            // Check fuzzy match score - use match with guard to avoid nesting
263            match self.matcher.fuzzy_match(&full_command, input) {
264                Some(score) if score > 0 => suggestions.push((full_command.clone(), score)),
265                _ => {}
266            }
267
268            // Also check just the operation ID - use match with guard to avoid nesting
269            match self.matcher.fuzzy_match(&operation_id_kebab, input) {
270                Some(score) if score > 0 => suggestions.push((full_command.clone(), score + 10)), // Bonus for direct match
271                _ => {}
272            }
273        }
274
275        // Sort by score and take top results
276        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/// Format search results for display
290#[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        // Basic result line
306        lines.push(format!(
307            "{}. aperture api {} {}",
308            number, result.api_context, result.command_path
309        ));
310
311        // Method and path
312        lines.push(format!(
313            "   {} {}",
314            result.command.method.to_uppercase(),
315            result.command.path
316        ));
317
318        // Description if available
319        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        // Show highlights
329        if !result.highlights.is_empty() {
330            lines.push(format!("   Matches: {}", result.highlights.join(", ")));
331        }
332
333        // Show parameters
334        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        // Show request body if present
348        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}