1use crate::cache::models::{CachedCommand, CachedSpec};
4use crate::utils::to_kebab_case;
5use std::collections::{BTreeMap, HashMap};
6
7#[derive(Debug, Clone)]
9pub struct ResolvedShortcut {
10 pub full_command: Vec<String>,
12 pub spec: CachedSpec,
14 pub command: CachedCommand,
16 pub confidence: u8,
18}
19
20#[derive(Debug)]
22pub enum ResolutionResult {
23 Resolved(Box<ResolvedShortcut>),
25 Ambiguous(Vec<ResolvedShortcut>),
27 NotFound,
29}
30
31#[allow(clippy::struct_field_names)]
33pub struct ShortcutResolver {
34 operation_map: HashMap<String, Vec<(String, CachedSpec, CachedCommand)>>,
36 method_path_map: HashMap<String, Vec<(String, CachedSpec, CachedCommand)>>,
38 tag_map: HashMap<String, Vec<(String, CachedSpec, CachedCommand)>>,
40}
41
42impl ShortcutResolver {
43 #[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 pub fn index_specs(&mut self, specs: &BTreeMap<String, CachedSpec>) {
55 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 let operation_kebab = to_kebab_case(&command.operation_id);
64
65 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 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 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 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 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 #[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 if let Some(matches) = self.try_operation_id_resolution(args) {
129 candidates.extend(matches);
130 }
131
132 if let Some(matches) = self.try_method_path_resolution(args) {
134 candidates.extend(matches);
135 }
136
137 if let Some(matches) = self.try_tag_resolution(args) {
139 candidates.extend(matches);
140 }
141
142 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 candidates.into_iter().next().map_or_else(
152 || {
153 eprintln!("Warning: Expected exactly one candidate but found none");
156 ResolutionResult::NotFound
157 },
158 |candidate| ResolutionResult::Resolved(Box::new(candidate)),
159 )
160 }
161 _ => {
162 candidates.sort_by(|a, b| b.confidence.cmp(&a.confidence));
164
165 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 candidates.into_iter().next().map_or_else(
176 || {
177 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 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, }
213 })
214 .collect()
215 })
216 }
217
218 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, }
249 })
250 .collect()
251 })
252 }
253
254 fn try_tag_resolution(&self, args: &[String]) -> Option<Vec<ResolvedShortcut>> {
256 let mut candidates = Vec::new();
257
258 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, });
274 }
275 }
276
277 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, });
304 }
305 }
306
307 if candidates.is_empty() {
308 None
309 } else {
310 Some(candidates)
311 }
312 }
313
314 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 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), });
345 }
346 }
347 }
348
349 if candidates.is_empty() {
350 None
351 } else {
352 Some(candidates)
353 }
354 }
355
356 #[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}