1use super::{OperationIndex, OperationSummary};
11use std::fmt;
12
13#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum Selector {
16 OperationId(String),
17 MethodPath { method: String, path: String },
18 Tag(String),
19}
20
21impl Selector {
22 pub fn parse(input: &str) -> Result<Self, SelectorParseError> {
29 let trimmed = input.trim();
30 if trimmed.is_empty() {
31 return Err(SelectorParseError::Empty);
32 }
33
34 if let Some(rest) = trimmed.strip_prefix("tag:") {
35 let tag = rest.trim();
36 if tag.is_empty() {
37 return Err(SelectorParseError::EmptyTag);
38 }
39 return Ok(Self::Tag(tag.to_string()));
40 }
41
42 if let Some((method, path)) = split_method_path(trimmed) {
45 return Ok(Self::MethodPath {
46 method: method.to_ascii_uppercase(),
47 path: path.to_string(),
48 });
49 }
50
51 if trimmed.chars().any(char::is_whitespace) {
52 return Err(SelectorParseError::WhitespaceInOpId(trimmed.to_string()));
53 }
54
55 Ok(Self::OperationId(trimmed.to_string()))
56 }
57}
58
59fn split_method_path(s: &str) -> Option<(&str, &str)> {
60 let (head, tail) = s.split_once(char::is_whitespace)?;
61 let tail = tail.trim_start();
62 if !tail.starts_with('/') {
63 return None;
64 }
65 let head_upper = head.to_ascii_uppercase();
66 if matches!(
67 head_upper.as_str(),
68 "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS" | "TRACE"
69 ) {
70 Some((head, tail))
71 } else {
72 None
73 }
74}
75
76impl fmt::Display for Selector {
77 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
78 match self {
79 Self::OperationId(id) => f.write_str(id),
80 Self::MethodPath { method, path } => write!(f, "{method} {path}"),
81 Self::Tag(t) => write!(f, "tag:{t}"),
82 }
83 }
84}
85
86#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
87pub enum SelectorParseError {
88 #[error("selector is empty")]
89 Empty,
90 #[error("`tag:` selector has no tag name")]
91 EmptyTag,
92 #[error("selector `{0}` contains whitespace but is not a `METHOD /path` form")]
93 WhitespaceInOpId(String),
94}
95
96#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
98pub enum SelectorResolveError {
99 #[error(
100 "no operation with id `{requested}`{}",
101 format_suggestion(.suggestion.as_deref())
102 )]
103 UnknownOperationId {
104 requested: String,
105 suggestion: Option<String>,
106 },
107 #[error(
108 "no operation at `{method} {path}`{}",
109 format_suggestion(.suggestion.as_deref())
110 )]
111 UnknownMethodPath {
112 method: String,
113 path: String,
114 suggestion: Option<String>,
115 },
116 #[error(
117 "no operations tagged `{requested}`{}",
118 format_suggestion(.suggestion.as_deref())
119 )]
120 UnknownTag {
121 requested: String,
122 suggestion: Option<String>,
123 },
124}
125
126fn format_suggestion(s: Option<&str>) -> String {
127 match s {
128 Some(s) => format!(". Did you mean `{s}`?"),
129 None => String::new(),
130 }
131}
132
133#[derive(Debug, Clone, Default)]
135pub struct Resolution {
136 pub operations: Vec<OperationSummary>,
138 pub duplicates: Vec<String>,
140}
141
142pub fn resolve(
147 selectors: &[Selector],
148 index: &OperationIndex,
149) -> Result<Resolution, SelectorResolveError> {
150 let mut out: Vec<OperationSummary> = Vec::new();
151 let mut duplicates: Vec<String> = Vec::new();
152 let mut seen_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
153
154 for sel in selectors {
155 match resolve_one(sel, index)? {
156 ResolvedOne::Single(op) => {
157 if seen_ids.insert(op.operation_id.clone()) {
158 out.push(op);
159 } else {
160 duplicates.push(sel.to_string());
161 }
162 }
163 ResolvedOne::Many(ops) => {
164 for op in ops {
165 if seen_ids.insert(op.operation_id.clone()) {
166 out.push(op);
167 } else {
168 duplicates.push(format!("{sel} → {}", op.operation_id));
169 }
170 }
171 }
172 }
173 }
174
175 Ok(Resolution {
176 operations: out,
177 duplicates,
178 })
179}
180
181enum ResolvedOne {
182 Single(OperationSummary),
183 Many(Vec<OperationSummary>),
184}
185
186fn resolve_one(
187 sel: &Selector,
188 index: &OperationIndex,
189) -> Result<ResolvedOne, SelectorResolveError> {
190 match sel {
191 Selector::OperationId(id) => index
192 .operations()
193 .iter()
194 .find(|op| &op.operation_id == id)
195 .cloned()
196 .map(ResolvedOne::Single)
197 .ok_or_else(|| SelectorResolveError::UnknownOperationId {
198 requested: id.clone(),
199 suggestion: suggest_op_id(id, index),
200 }),
201 Selector::MethodPath { method, path } => index
202 .operations()
203 .iter()
204 .find(|op| &op.method == method && &op.path == path)
205 .cloned()
206 .map(ResolvedOne::Single)
207 .ok_or_else(|| SelectorResolveError::UnknownMethodPath {
208 method: method.clone(),
209 path: path.clone(),
210 suggestion: suggest_method_path(method, path, index),
211 }),
212 Selector::Tag(tag) => {
213 let matched: Vec<OperationSummary> = index
214 .operations()
215 .iter()
216 .filter(|op| op.tags.iter().any(|t| t == tag))
217 .cloned()
218 .collect();
219 if matched.is_empty() {
220 Err(SelectorResolveError::UnknownTag {
221 requested: tag.clone(),
222 suggestion: suggest_tag(tag, index),
223 })
224 } else {
225 Ok(ResolvedOne::Many(matched))
226 }
227 }
228 }
229}
230
231fn levenshtein(a: &str, b: &str) -> usize {
235 if a.is_empty() {
236 return b.chars().count();
237 }
238 if b.is_empty() {
239 return a.chars().count();
240 }
241 let a_chars: Vec<char> = a.chars().collect();
242 let b_chars: Vec<char> = b.chars().collect();
243 let m = a_chars.len();
244 let n = b_chars.len();
245
246 let mut prev: Vec<usize> = (0..=n).collect();
247 let mut curr: Vec<usize> = vec![0; n + 1];
248
249 for i in 1..=m {
250 curr[0] = i;
251 for j in 1..=n {
252 let cost = if a_chars[i - 1] == b_chars[j - 1] {
253 0
254 } else {
255 1
256 };
257 curr[j] = (prev[j] + 1).min(curr[j - 1] + 1).min(prev[j - 1] + cost);
258 }
259 std::mem::swap(&mut prev, &mut curr);
260 }
261 prev[n]
262}
263
264fn suggest_threshold(needle: &str) -> usize {
266 let len = needle.chars().count();
267 if len <= 4 {
268 1
269 } else if len <= 8 {
270 2
271 } else {
272 3
273 }
274}
275
276fn suggest_op_id(needle: &str, index: &OperationIndex) -> Option<String> {
277 let threshold = suggest_threshold(needle);
278 index
279 .operations()
280 .iter()
281 .filter(|op| !op.operation_id.is_empty())
282 .map(|op| {
283 (
284 op.operation_id.clone(),
285 levenshtein(needle, &op.operation_id),
286 )
287 })
288 .filter(|(_, d)| *d <= threshold)
289 .min_by_key(|(_, d)| *d)
290 .map(|(id, _)| id)
291}
292
293fn suggest_method_path(method: &str, path: &str, index: &OperationIndex) -> Option<String> {
294 let threshold = suggest_threshold(path);
295 let candidate = index
296 .operations()
297 .iter()
298 .filter(|op| op.method == method)
299 .map(|op| (op.path.clone(), levenshtein(path, &op.path)))
300 .filter(|(_, d)| *d <= threshold)
301 .min_by_key(|(_, d)| *d);
302 candidate.map(|(p, _)| format!("{method} {p}"))
303}
304
305fn suggest_tag(needle: &str, index: &OperationIndex) -> Option<String> {
306 let threshold = suggest_threshold(needle);
307 let mut best: Option<(String, usize)> = None;
308 let mut seen: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
309 for op in index.operations() {
310 for t in &op.tags {
311 if !seen.insert(t.as_str()) {
312 continue;
313 }
314 let d = levenshtein(needle, t);
315 if d <= threshold && best.as_ref().is_none_or(|(_, bd)| d < *bd) {
316 best = Some((t.clone(), d));
317 }
318 }
319 }
320 best.map(|(t, _)| format!("tag:{t}"))
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326 use crate::server::OperationSummary;
327
328 fn op(id: &str, method: &str, path: &str, tags: &[&str]) -> OperationSummary {
329 OperationSummary {
330 operation_id: id.to_string(),
331 method: method.to_string(),
332 path: path.to_string(),
333 tags: tags.iter().map(|s| s.to_string()).collect(),
334 supports_streaming: false,
335 }
336 }
337
338 fn idx(ops: Vec<OperationSummary>) -> OperationIndex {
339 OperationIndex::from_summaries(ops)
340 }
341
342 #[test]
345 fn parse_op_id() {
346 assert_eq!(
347 Selector::parse("createResponse").unwrap(),
348 Selector::OperationId("createResponse".into())
349 );
350 }
351
352 #[test]
353 fn parse_method_path_uppercases_verb() {
354 assert_eq!(
355 Selector::parse("post /v1/messages").unwrap(),
356 Selector::MethodPath {
357 method: "POST".into(),
358 path: "/v1/messages".into()
359 }
360 );
361 }
362
363 #[test]
364 fn parse_tag() {
365 assert_eq!(
366 Selector::parse("tag:Chat").unwrap(),
367 Selector::Tag("Chat".into())
368 );
369 }
370
371 #[test]
372 fn parse_empty_errors() {
373 assert!(matches!(
374 Selector::parse(" "),
375 Err(SelectorParseError::Empty)
376 ));
377 assert!(matches!(
378 Selector::parse("tag:"),
379 Err(SelectorParseError::EmptyTag)
380 ));
381 }
382
383 #[test]
384 fn parse_whitespace_in_op_id_errors() {
385 assert!(matches!(
388 Selector::parse("foo bar"),
389 Err(SelectorParseError::WhitespaceInOpId(_))
390 ));
391 }
392
393 #[test]
394 fn parse_strips_whitespace() {
395 assert_eq!(
396 Selector::parse(" createResponse ").unwrap(),
397 Selector::OperationId("createResponse".into())
398 );
399 }
400
401 #[test]
404 fn resolve_op_id_hit() {
405 let i = idx(vec![op(
406 "createResponse",
407 "POST",
408 "/responses",
409 &["Responses"],
410 )]);
411 let r = resolve(&[Selector::OperationId("createResponse".into())], &i).unwrap();
412 assert_eq!(r.operations.len(), 1);
413 assert_eq!(r.operations[0].operation_id, "createResponse");
414 }
415
416 #[test]
417 fn resolve_op_id_miss_suggests() {
418 let i = idx(vec![op("createResponse", "POST", "/responses", &[])]);
419 let err = resolve(&[Selector::OperationId("createRespons".into())], &i).unwrap_err();
420 match err {
421 SelectorResolveError::UnknownOperationId { suggestion, .. } => {
422 assert_eq!(suggestion.as_deref(), Some("createResponse"));
423 }
424 _ => panic!("wrong variant"),
425 }
426 }
427
428 #[test]
429 fn resolve_method_path_hit() {
430 let i = idx(vec![op("m", "POST", "/v1/messages", &[])]);
431 let r = resolve(
432 &[Selector::MethodPath {
433 method: "POST".into(),
434 path: "/v1/messages".into(),
435 }],
436 &i,
437 )
438 .unwrap();
439 assert_eq!(r.operations.len(), 1);
440 }
441
442 #[test]
443 fn resolve_method_path_miss_suggests_same_method_only() {
444 let i = idx(vec![
445 op("a", "POST", "/v1/messages", &[]),
446 op("b", "GET", "/v1/messagex", &[]),
447 ]);
448 let err = resolve(
449 &[Selector::MethodPath {
450 method: "POST".into(),
451 path: "/v1/messagex".into(),
452 }],
453 &i,
454 )
455 .unwrap_err();
456 match err {
457 SelectorResolveError::UnknownMethodPath { suggestion, .. } => {
458 assert_eq!(suggestion.as_deref(), Some("POST /v1/messages"));
460 }
461 _ => panic!("wrong variant"),
462 }
463 }
464
465 #[test]
466 fn resolve_tag_expands() {
467 let i = idx(vec![
468 op("a", "GET", "/a", &["Files"]),
469 op("b", "POST", "/b", &["Files"]),
470 op("c", "POST", "/c", &["Chat"]),
471 ]);
472 let r = resolve(&[Selector::Tag("Files".into())], &i).unwrap();
473 assert_eq!(r.operations.len(), 2);
474 assert_eq!(r.operations[0].operation_id, "a");
475 assert_eq!(r.operations[1].operation_id, "b");
476 }
477
478 #[test]
479 fn resolve_tag_miss_suggests() {
480 let i = idx(vec![op("a", "GET", "/a", &["Embeddings"])]);
481 let err = resolve(&[Selector::Tag("Embedding".into())], &i).unwrap_err();
482 match err {
483 SelectorResolveError::UnknownTag { suggestion, .. } => {
484 assert_eq!(suggestion.as_deref(), Some("tag:Embeddings"));
485 }
486 _ => panic!("wrong variant"),
487 }
488 }
489
490 #[test]
491 fn resolve_dedup_within_run() {
492 let i = idx(vec![op("a", "GET", "/a", &["T"])]);
493 let r = resolve(
494 &[Selector::OperationId("a".into()), Selector::Tag("T".into())],
495 &i,
496 )
497 .unwrap();
498 assert_eq!(r.operations.len(), 1);
499 assert_eq!(r.duplicates.len(), 1);
500 assert!(r.duplicates[0].contains("tag:T"));
501 }
502
503 #[test]
504 fn resolve_preserves_input_order() {
505 let i = idx(vec![
506 op("a", "GET", "/a", &[]),
507 op("b", "POST", "/b", &[]),
508 op("c", "PUT", "/c", &[]),
509 ]);
510 let r = resolve(
511 &[
512 Selector::OperationId("c".into()),
513 Selector::OperationId("a".into()),
514 Selector::OperationId("b".into()),
515 ],
516 &i,
517 )
518 .unwrap();
519 let ids: Vec<&str> = r
520 .operations
521 .iter()
522 .map(|o| o.operation_id.as_str())
523 .collect();
524 assert_eq!(ids, ["c", "a", "b"]);
525 }
526
527 #[test]
528 fn levenshtein_basics() {
529 assert_eq!(levenshtein("", ""), 0);
530 assert_eq!(levenshtein("abc", "abc"), 0);
531 assert_eq!(levenshtein("abc", "abd"), 1);
532 assert_eq!(levenshtein("kitten", "sitting"), 3);
533 }
534}