Skip to main content

aperture_cli/
shortcuts.rs

1//! Command shortcuts and aliases for improved CLI usability
2
3use crate::cache::models::{CachedCommand, CachedSpec};
4use crate::utils::to_kebab_case;
5use std::collections::{BTreeMap, HashMap};
6
7/// Represents a resolved command shortcut
8#[derive(Debug, Clone)]
9pub struct ResolvedShortcut {
10    /// The full command path that should be executed
11    pub full_command: Vec<String>,
12    /// The spec containing the command
13    pub spec: CachedSpec,
14    /// The resolved command
15    pub command: CachedCommand,
16    /// Confidence score (0-100, higher is better)
17    pub confidence: u8,
18}
19
20/// Command resolution result
21#[derive(Debug)]
22pub enum ResolutionResult {
23    /// Exact match found
24    Resolved(Box<ResolvedShortcut>),
25    /// Multiple possible matches
26    Ambiguous(Vec<ResolvedShortcut>),
27    /// No matches found
28    NotFound,
29}
30
31/// Command shortcut resolver
32#[allow(clippy::struct_field_names)]
33pub struct ShortcutResolver {
34    /// Map of operation IDs to specs and commands
35    operation_map: HashMap<String, Vec<(String, CachedSpec, CachedCommand)>>,
36    /// Map of HTTP method + path combinations
37    method_path_map: HashMap<String, Vec<(String, CachedSpec, CachedCommand)>>,
38    /// Map of tag-based shortcuts
39    tag_map: HashMap<String, Vec<(String, CachedSpec, CachedCommand)>>,
40}
41
42impl ShortcutResolver {
43    /// Create a new shortcut resolver
44    #[must_use]
45    pub fn new() -> Self {
46        Self {
47            operation_map: HashMap::new(),
48            method_path_map: HashMap::new(),
49            tag_map: HashMap::new(),
50        }
51    }
52
53    /// Index all available commands for shortcut resolution
54    pub fn index_specs(&mut self, specs: &BTreeMap<String, CachedSpec>) {
55        // Clear existing indexes
56        self.operation_map.clear();
57        self.method_path_map.clear();
58        self.tag_map.clear();
59
60        for (api_name, spec) in specs {
61            for command in &spec.commands {
62                // Index by operation ID (both original and kebab-case)
63                let operation_kebab = to_kebab_case(&command.operation_id);
64
65                // Original operation ID
66                if !command.operation_id.is_empty() {
67                    self.operation_map
68                        .entry(command.operation_id.clone())
69                        .or_default()
70                        .push((api_name.clone(), spec.clone(), command.clone()));
71                }
72
73                // Kebab-case operation ID
74                if operation_kebab != command.operation_id {
75                    self.operation_map
76                        .entry(operation_kebab.clone())
77                        .or_default()
78                        .push((api_name.clone(), spec.clone(), command.clone()));
79                }
80
81                // Index by HTTP method + path
82                let method = command.method.to_uppercase();
83                let path = &command.path;
84                let method_path_key = format!("{method} {path}");
85                self.method_path_map
86                    .entry(method_path_key)
87                    .or_default()
88                    .push((api_name.clone(), spec.clone(), command.clone()));
89
90                // Index by tags
91                for tag in &command.tags {
92                    let tag_key = to_kebab_case(tag);
93                    self.tag_map.entry(tag_key.clone()).or_default().push((
94                        api_name.clone(),
95                        spec.clone(),
96                        command.clone(),
97                    ));
98
99                    // Also index tag + operation combinations
100                    let tag_operation_key = format!("{tag_key} {operation_kebab}");
101                    self.tag_map.entry(tag_operation_key).or_default().push((
102                        api_name.clone(),
103                        spec.clone(),
104                        command.clone(),
105                    ));
106                }
107            }
108        }
109    }
110
111    /// Resolve a command shortcut to full command path
112    ///
113    /// # Panics
114    ///
115    /// Panics if candidates is empty when exactly one match is expected.
116    /// This should not happen in practice due to the length check.
117    #[must_use]
118    pub fn resolve_shortcut(&self, args: &[String]) -> ResolutionResult {
119        if args.is_empty() {
120            return ResolutionResult::NotFound;
121        }
122
123        let mut candidates = Vec::new();
124
125        // Try different resolution strategies in order of preference
126
127        // 1. Direct operation ID match
128        if let Some(matches) = self.try_operation_id_resolution(args) {
129            candidates.extend(matches);
130        }
131
132        // 2. HTTP method + path resolution
133        if let Some(matches) = self.try_method_path_resolution(args) {
134            candidates.extend(matches);
135        }
136
137        // 3. Tag-based resolution
138        if let Some(matches) = self.try_tag_resolution(args) {
139            candidates.extend(matches);
140        }
141
142        // 4. Partial matching (fuzzy) - only if no candidates found yet
143        if candidates.is_empty() {
144            candidates.extend(self.try_partial_matching(args).unwrap_or_default());
145        }
146
147        match candidates.len() {
148            0 => ResolutionResult::NotFound,
149            1 => {
150                // Handle the single candidate case safely
151                candidates.into_iter().next().map_or_else(
152                    || {
153                        // This should never happen given len() == 1, but handle defensively
154                        // ast-grep-ignore: no-println
155                        eprintln!("Warning: Expected exactly one candidate but found none");
156                        ResolutionResult::NotFound
157                    },
158                    |candidate| ResolutionResult::Resolved(Box::new(candidate)),
159                )
160            }
161            _ => {
162                // Sort by confidence score (descending)
163                candidates.sort_by(|a, b| b.confidence.cmp(&a.confidence));
164
165                // Check if the top candidate has significantly higher confidence
166                let has_high_confidence = candidates[0].confidence >= 85
167                    && (candidates.len() == 1
168                        || candidates[0].confidence > candidates[1].confidence + 10);
169
170                if !has_high_confidence {
171                    return ResolutionResult::Ambiguous(candidates);
172                }
173
174                // Handle the high-confidence candidate case safely
175                candidates.into_iter().next().map_or_else(
176                    || {
177                        // This should never happen given we just accessed candidates[0], but handle defensively
178                        // ast-grep-ignore: no-println
179                        eprintln!("Warning: Expected candidates after sorting but found none");
180                        ResolutionResult::NotFound
181                    },
182                    |candidate| ResolutionResult::Resolved(Box::new(candidate)),
183                )
184            }
185        }
186    }
187
188    /// Try to resolve using direct operation ID matching
189    fn try_operation_id_resolution(&self, args: &[String]) -> Option<Vec<ResolvedShortcut>> {
190        let operation_id = &args[0];
191
192        self.operation_map.get(operation_id).map(|matches| {
193            matches
194                .iter()
195                .map(|(api_name, spec, command)| {
196                    let tag = command
197                        .tags
198                        .first()
199                        .map_or_else(|| "api".to_string(), |t| to_kebab_case(t));
200                    let operation_kebab = to_kebab_case(&command.operation_id);
201
202                    ResolvedShortcut {
203                        full_command: vec![
204                            "api".to_string(),
205                            api_name.clone(),
206                            tag,
207                            operation_kebab,
208                        ],
209                        spec: spec.clone(),
210                        command: command.clone(),
211                        confidence: 95, // High confidence for exact operation ID match
212                    }
213                })
214                .collect()
215        })
216    }
217
218    /// Try to resolve using HTTP method + path
219    fn try_method_path_resolution(&self, args: &[String]) -> Option<Vec<ResolvedShortcut>> {
220        if args.len() < 2 {
221            return None;
222        }
223
224        let method = args[0].to_uppercase();
225        let path = &args[1];
226        let method_path_key = format!("{method} {path}");
227
228        self.method_path_map.get(&method_path_key).map(|matches| {
229            matches
230                .iter()
231                .map(|(api_name, spec, command)| {
232                    let tag = command
233                        .tags
234                        .first()
235                        .map_or_else(|| "api".to_string(), |t| to_kebab_case(t));
236                    let operation_kebab = to_kebab_case(&command.operation_id);
237
238                    ResolvedShortcut {
239                        full_command: vec![
240                            "api".to_string(),
241                            api_name.clone(),
242                            tag,
243                            operation_kebab,
244                        ],
245                        spec: spec.clone(),
246                        command: command.clone(),
247                        confidence: 90, // High confidence for exact method+path match
248                    }
249                })
250                .collect()
251        })
252    }
253
254    /// Try to resolve using tag-based matching
255    fn try_tag_resolution(&self, args: &[String]) -> Option<Vec<ResolvedShortcut>> {
256        let mut candidates = Vec::new();
257
258        // Try single tag lookup - convert to kebab-case for matching
259        let tag_kebab = to_kebab_case(&args[0]);
260        if let Some(matches) = self.tag_map.get(&tag_kebab) {
261            for (api_name, spec, command) in matches {
262                let tag = command
263                    .tags
264                    .first()
265                    .map_or_else(|| "api".to_string(), |t| to_kebab_case(t));
266                let operation_kebab = to_kebab_case(&command.operation_id);
267
268                candidates.push(ResolvedShortcut {
269                    full_command: vec!["api".to_string(), api_name.clone(), tag, operation_kebab],
270                    spec: spec.clone(),
271                    command: command.clone(),
272                    confidence: 70, // Medium confidence for tag-only match
273                });
274            }
275        }
276
277        // Try tag + operation combination if we have 2+ args
278        if args.len() < 2 {
279            return if candidates.is_empty() {
280                None
281            } else {
282                Some(candidates)
283            };
284        }
285
286        let tag = to_kebab_case(&args[0]);
287        let operation = to_kebab_case(&args[1]);
288        let tag_operation_key = format!("{tag} {operation}");
289
290        if let Some(matches) = self.tag_map.get(&tag_operation_key) {
291            for (api_name, spec, command) in matches {
292                let tag = command
293                    .tags
294                    .first()
295                    .map_or_else(|| "api".to_string(), |t| to_kebab_case(t));
296                let operation_kebab = to_kebab_case(&command.operation_id);
297
298                candidates.push(ResolvedShortcut {
299                    full_command: vec!["api".to_string(), api_name.clone(), tag, operation_kebab],
300                    spec: spec.clone(),
301                    command: command.clone(),
302                    confidence: 85, // Higher confidence for tag+operation match
303                });
304            }
305        }
306
307        if candidates.is_empty() {
308            None
309        } else {
310            Some(candidates)
311        }
312    }
313
314    /// Try partial matching using fuzzy logic
315    fn try_partial_matching(&self, args: &[String]) -> Option<Vec<ResolvedShortcut>> {
316        use fuzzy_matcher::skim::SkimMatcherV2;
317        use fuzzy_matcher::FuzzyMatcher;
318
319        let matcher = SkimMatcherV2::default().ignore_case();
320        let query = args.join(" ");
321        let mut candidates = Vec::new();
322
323        // Search through operation IDs
324        for (operation_id, matches) in &self.operation_map {
325            if let Some(score) = matcher.fuzzy_match(operation_id, &query) {
326                for (api_name, spec, command) in matches {
327                    let tag = command
328                        .tags
329                        .first()
330                        .map_or_else(|| "api".to_string(), |t| to_kebab_case(t));
331                    let operation_kebab = to_kebab_case(&command.operation_id);
332
333                    candidates.push(ResolvedShortcut {
334                        full_command: vec![
335                            "api".to_string(),
336                            api_name.clone(),
337                            tag,
338                            operation_kebab,
339                        ],
340                        spec: spec.clone(),
341                        command: command.clone(),
342                        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
343                        confidence: std::cmp::min(60, (score / 10).max(20) as u8), // Scale fuzzy score
344                    });
345                }
346            }
347        }
348
349        if candidates.is_empty() {
350            None
351        } else {
352            Some(candidates)
353        }
354    }
355
356    /// Generate suggestions for ambiguous matches
357    #[must_use]
358    pub fn format_ambiguous_suggestions(&self, matches: &[ResolvedShortcut]) -> String {
359        let mut suggestions = Vec::new();
360
361        for (i, shortcut) in matches.iter().take(5).enumerate() {
362            let cmd = shortcut.full_command.join(" ");
363            let desc = shortcut
364                .command
365                .description
366                .as_deref()
367                .unwrap_or("No description");
368            let num = i + 1;
369            suggestions.push(format!("{num}. aperture {cmd} - {desc}"));
370        }
371
372        format!(
373            "Multiple commands match. Did you mean:\n{}",
374            suggestions.join("\n")
375        )
376    }
377}
378
379impl Default for ShortcutResolver {
380    fn default() -> Self {
381        Self::new()
382    }
383}