1use crate::cache::models::{CachedCommand, CachedSpec};
4use crate::constants;
5use crate::utils::to_kebab_case;
6use std::collections::{BTreeMap, HashMap};
7
8fn build_full_command(api_name: &str, command: &CachedCommand) -> Vec<String> {
11 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#[derive(Debug, Clone)]
38pub struct ResolvedShortcut {
39 pub full_command: Vec<String>,
41 pub spec: CachedSpec,
43 pub command: CachedCommand,
45 pub confidence: u8,
47}
48
49#[derive(Debug)]
51pub enum ResolutionResult {
52 Resolved(Box<ResolvedShortcut>),
54 Ambiguous(Vec<ResolvedShortcut>),
56 NotFound,
58}
59
60#[allow(clippy::struct_field_names)]
62pub struct ShortcutResolver {
63 operation_map: HashMap<String, Vec<(String, CachedSpec, CachedCommand)>>,
65 method_path_map: HashMap<String, Vec<(String, CachedSpec, CachedCommand)>>,
67 tag_map: HashMap<String, Vec<(String, CachedSpec, CachedCommand)>>,
69}
70
71impl ShortcutResolver {
72 #[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 pub fn index_specs(&mut self, specs: &BTreeMap<String, CachedSpec>) {
84 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 let operation_kebab = to_kebab_case(&command.operation_id);
93
94 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 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 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 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 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 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 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 #[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 if let Some(matches) = self.try_operation_id_resolution(args) {
191 candidates.extend(matches);
192 }
193
194 if let Some(matches) = self.try_method_path_resolution(args) {
196 candidates.extend(matches);
197 }
198
199 if let Some(matches) = self.try_tag_resolution(args) {
201 candidates.extend(matches);
202 }
203
204 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 candidates.into_iter().next().map_or_else(
214 || {
215 eprintln!("Warning: Expected exactly one candidate but found none");
218 ResolutionResult::NotFound
219 },
220 |candidate| ResolutionResult::Resolved(Box::new(candidate)),
221 )
222 }
223 _ => {
224 candidates.sort_by(|a, b| b.confidence.cmp(&a.confidence));
226
227 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 candidates.into_iter().next().map_or_else(
238 || {
239 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 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, })
263 .collect()
264 })
265 }
266
267 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, })
286 .collect()
287 })
288 }
289
290 fn try_tag_resolution(&self, args: &[String]) -> Option<Vec<ResolvedShortcut>> {
292 let mut candidates = Vec::new();
293
294 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, });
304 }
305 }
306
307 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, });
328 }
329 }
330
331 if candidates.is_empty() {
332 None
333 } else {
334 Some(candidates)
335 }
336 }
337
338 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 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), });
358 }
359 }
360 }
361
362 if candidates.is_empty() {
363 None
364 } else {
365 Some(candidates)
366 }
367 }
368
369 #[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}