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)
143        if candidates.is_empty() {
144            if let Some(matches) = self.try_partial_matching(args) {
145                candidates.extend(matches);
146            }
147        }
148
149        match candidates.len() {
150            0 => ResolutionResult::NotFound,
151            1 => {
152                // Handle the single candidate case safely
153                candidates.into_iter().next().map_or_else(
154                    || {
155                        // This should never happen given len() == 1, but handle defensively
156                        eprintln!("Warning: Expected exactly one candidate but found none");
157                        ResolutionResult::NotFound
158                    },
159                    |candidate| ResolutionResult::Resolved(Box::new(candidate)),
160                )
161            }
162            _ => {
163                // Sort by confidence score (descending)
164                candidates.sort_by(|a, b| b.confidence.cmp(&a.confidence));
165
166                // If the top candidate has significantly higher confidence, use it
167                if candidates[0].confidence >= 85
168                    && (candidates.len() == 1
169                        || candidates[0].confidence > candidates[1].confidence + 10)
170                {
171                    // Handle the high-confidence candidate case safely
172                    candidates.into_iter().next().map_or_else(
173                        || {
174                            // This should never happen given we just accessed candidates[0], but handle defensively
175                            eprintln!("Warning: Expected candidates after sorting but found none");
176                            ResolutionResult::NotFound
177                        },
178                        |candidate| ResolutionResult::Resolved(Box::new(candidate)),
179                    )
180                } else {
181                    ResolutionResult::Ambiguous(candidates)
182                }
183            }
184        }
185    }
186
187    /// Try to resolve using direct operation ID matching
188    fn try_operation_id_resolution(&self, args: &[String]) -> Option<Vec<ResolvedShortcut>> {
189        let operation_id = &args[0];
190
191        self.operation_map.get(operation_id).map(|matches| {
192            matches
193                .iter()
194                .map(|(api_name, spec, command)| {
195                    let tag = command
196                        .tags
197                        .first()
198                        .map_or_else(|| "api".to_string(), |t| to_kebab_case(t));
199                    let operation_kebab = to_kebab_case(&command.operation_id);
200
201                    ResolvedShortcut {
202                        full_command: vec![
203                            "api".to_string(),
204                            api_name.clone(),
205                            tag,
206                            operation_kebab,
207                        ],
208                        spec: spec.clone(),
209                        command: command.clone(),
210                        confidence: 95, // High confidence for exact operation ID match
211                    }
212                })
213                .collect()
214        })
215    }
216
217    /// Try to resolve using HTTP method + path
218    fn try_method_path_resolution(&self, args: &[String]) -> Option<Vec<ResolvedShortcut>> {
219        if args.len() < 2 {
220            return None;
221        }
222
223        let method = args[0].to_uppercase();
224        let path = &args[1];
225        let method_path_key = format!("{method} {path}");
226
227        self.method_path_map.get(&method_path_key).map(|matches| {
228            matches
229                .iter()
230                .map(|(api_name, spec, command)| {
231                    let tag = command
232                        .tags
233                        .first()
234                        .map_or_else(|| "api".to_string(), |t| to_kebab_case(t));
235                    let operation_kebab = to_kebab_case(&command.operation_id);
236
237                    ResolvedShortcut {
238                        full_command: vec![
239                            "api".to_string(),
240                            api_name.clone(),
241                            tag,
242                            operation_kebab,
243                        ],
244                        spec: spec.clone(),
245                        command: command.clone(),
246                        confidence: 90, // High confidence for exact method+path match
247                    }
248                })
249                .collect()
250        })
251    }
252
253    /// Try to resolve using tag-based matching
254    fn try_tag_resolution(&self, args: &[String]) -> Option<Vec<ResolvedShortcut>> {
255        let mut candidates = Vec::new();
256
257        // Try single tag lookup - convert to kebab-case for matching
258        let tag_kebab = to_kebab_case(&args[0]);
259        if let Some(matches) = self.tag_map.get(&tag_kebab) {
260            for (api_name, spec, command) in matches {
261                let tag = command
262                    .tags
263                    .first()
264                    .map_or_else(|| "api".to_string(), |t| to_kebab_case(t));
265                let operation_kebab = to_kebab_case(&command.operation_id);
266
267                candidates.push(ResolvedShortcut {
268                    full_command: vec!["api".to_string(), api_name.clone(), tag, operation_kebab],
269                    spec: spec.clone(),
270                    command: command.clone(),
271                    confidence: 70, // Medium confidence for tag-only match
272                });
273            }
274        }
275
276        // Try tag + operation combination if we have 2+ args
277        if args.len() >= 2 {
278            let tag = to_kebab_case(&args[0]);
279            let operation = to_kebab_case(&args[1]);
280            let tag_operation_key = format!("{tag} {operation}");
281            if let Some(matches) = self.tag_map.get(&tag_operation_key) {
282                for (api_name, spec, command) in matches {
283                    let tag = command
284                        .tags
285                        .first()
286                        .map_or_else(|| "api".to_string(), |t| to_kebab_case(t));
287                    let operation_kebab = to_kebab_case(&command.operation_id);
288
289                    candidates.push(ResolvedShortcut {
290                        full_command: vec![
291                            "api".to_string(),
292                            api_name.clone(),
293                            tag,
294                            operation_kebab,
295                        ],
296                        spec: spec.clone(),
297                        command: command.clone(),
298                        confidence: 85, // Higher confidence for tag+operation match
299                    });
300                }
301            }
302        }
303
304        if candidates.is_empty() {
305            None
306        } else {
307            Some(candidates)
308        }
309    }
310
311    /// Try partial matching using fuzzy logic
312    fn try_partial_matching(&self, args: &[String]) -> Option<Vec<ResolvedShortcut>> {
313        use fuzzy_matcher::skim::SkimMatcherV2;
314        use fuzzy_matcher::FuzzyMatcher;
315
316        let matcher = SkimMatcherV2::default().ignore_case();
317        let query = args.join(" ");
318        let mut candidates = Vec::new();
319
320        // Search through operation IDs
321        for (operation_id, matches) in &self.operation_map {
322            if let Some(score) = matcher.fuzzy_match(operation_id, &query) {
323                for (api_name, spec, command) in matches {
324                    let tag = command
325                        .tags
326                        .first()
327                        .map_or_else(|| "api".to_string(), |t| to_kebab_case(t));
328                    let operation_kebab = to_kebab_case(&command.operation_id);
329
330                    candidates.push(ResolvedShortcut {
331                        full_command: vec![
332                            "api".to_string(),
333                            api_name.clone(),
334                            tag,
335                            operation_kebab,
336                        ],
337                        spec: spec.clone(),
338                        command: command.clone(),
339                        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
340                        confidence: std::cmp::min(60, (score / 10).max(20) as u8), // Scale fuzzy score
341                    });
342                }
343            }
344        }
345
346        if candidates.is_empty() {
347            None
348        } else {
349            Some(candidates)
350        }
351    }
352
353    /// Generate suggestions for ambiguous matches
354    #[must_use]
355    pub fn format_ambiguous_suggestions(&self, matches: &[ResolvedShortcut]) -> String {
356        let mut suggestions = Vec::new();
357
358        for (i, shortcut) in matches.iter().take(5).enumerate() {
359            let cmd = shortcut.full_command.join(" ");
360            let desc = shortcut
361                .command
362                .description
363                .as_deref()
364                .unwrap_or("No description");
365            let num = i + 1;
366            suggestions.push(format!("{num}. aperture {cmd} - {desc}"));
367        }
368
369        format!(
370            "Multiple commands match. Did you mean:\n{}",
371            suggestions.join("\n")
372        )
373    }
374}
375
376impl Default for ShortcutResolver {
377    fn default() -> Self {
378        Self::new()
379    }
380}