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