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/// Command searcher for finding operations across APIs
32pub struct CommandSearcher {
33    /// Fuzzy matcher for similarity scoring
34    matcher: SkimMatcherV2,
35}
36
37impl CommandSearcher {
38    /// Create a new command searcher
39    #[must_use]
40    pub fn new() -> Self {
41        Self {
42            matcher: SkimMatcherV2::default().ignore_case(),
43        }
44    }
45
46    /// Search for commands across specifications
47    ///
48    /// # Arguments
49    /// * `specs` - Map of API context names to cached specifications
50    /// * `query` - The search query (keywords or regex)
51    /// * `api_filter` - Optional API context to limit search
52    ///
53    /// # Returns
54    /// A vector of search results sorted by relevance
55    ///
56    /// # Errors
57    /// Returns an error if regex compilation fails
58    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        // Try to compile as regex first
67        let regex_pattern = Regex::new(query).ok();
68
69        for (api_name, spec) in specs {
70            // Apply API filter if specified
71            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                // Build searchable text from command attributes with pre-allocated capacity
82                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                // Use format! for more efficient single allocation
87                let search_text = format!(
88                    "{operation_id_kebab} {} {} {} {summary} {description}",
89                    command.operation_id, command.method, command.path
90                );
91
92                // Score based on different matching strategies
93                if let Some(ref regex) = regex_pattern {
94                    if regex.is_match(&search_text) {
95                        // Dynamic scoring based on match quality for regex
96                        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                    // Fuzzy match on complete search text
105                    if let Some(score) = self.matcher.fuzzy_match(&search_text, query) {
106                        total_score += score;
107                    }
108
109                    // Bonus score for exact substring matches
110                    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                    // Also check original operation ID
116                    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                // Only include results with positive scores
137                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        // Sort by score (highest first)
156        results.sort_by(|a, b| b.score.cmp(&a.score));
157
158        Ok(results)
159    }
160
161    /// Find similar commands to a given input
162    ///
163    /// This is used for "did you mean?" suggestions on errors
164    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            // Check fuzzy match score
181            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            // Also check just the operation ID
188            if let Some(score) = self.matcher.fuzzy_match(&operation_id_kebab, input) {
189                if score > 0 {
190                    suggestions.push((full_command.clone(), score + 10)); // Bonus for direct match
191                }
192            }
193        }
194
195        // Sort by score and take top results
196        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/// Format search results for display
210#[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        // Basic result line
226        lines.push(format!(
227            "{}. aperture api {} {}",
228            number, result.api_context, result.command_path
229        ));
230
231        // Method and path
232        lines.push(format!(
233            "   {} {}",
234            result.command.method.to_uppercase(),
235            result.command.path
236        ));
237
238        // Description if available
239        if let Some(ref summary) = result.command.summary {
240            lines.push(format!("   {summary}"));
241        }
242
243        if verbose {
244            // Show highlights
245            if !result.highlights.is_empty() {
246                lines.push(format!("   Matches: {}", result.highlights.join(", ")));
247            }
248
249            // Show parameters
250            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            // Show request body if present
264            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}