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 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 candidates.into_iter().next().map_or_else(
154 || {
155 eprintln!("Warning: Expected exactly one candidate but found none");
157 ResolutionResult::NotFound
158 },
159 |candidate| ResolutionResult::Resolved(Box::new(candidate)),
160 )
161 }
162 _ => {
163 candidates.sort_by(|a, b| b.confidence.cmp(&a.confidence));
165
166 if candidates[0].confidence >= 85
168 && (candidates.len() == 1
169 || candidates[0].confidence > candidates[1].confidence + 10)
170 {
171 candidates.into_iter().next().map_or_else(
173 || {
174 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 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, }
212 })
213 .collect()
214 })
215 }
216
217 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, }
248 })
249 .collect()
250 })
251 }
252
253 fn try_tag_resolution(&self, args: &[String]) -> Option<Vec<ResolvedShortcut>> {
255 let mut candidates = Vec::new();
256
257 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, });
273 }
274 }
275
276 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, });
300 }
301 }
302 }
303
304 if candidates.is_empty() {
305 None
306 } else {
307 Some(candidates)
308 }
309 }
310
311 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 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), });
342 }
343 }
344 }
345
346 if candidates.is_empty() {
347 None
348 } else {
349 Some(candidates)
350 }
351 }
352
353 #[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}