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('/') || !is_http_method_token(head) {
63 return None;
64 }
65 Some((head, tail))
66}
67
68fn is_http_method_token(value: &str) -> bool {
72 !value.is_empty()
73 && value.bytes().all(|byte| {
74 byte.is_ascii_alphanumeric()
75 || matches!(
76 byte,
77 b'!' | b'#'
78 | b'$'
79 | b'%'
80 | b'&'
81 | b'\''
82 | b'*'
83 | b'+'
84 | b'-'
85 | b'.'
86 | b'^'
87 | b'_'
88 | b'`'
89 | b'|'
90 | b'~'
91 )
92 })
93}
94
95impl fmt::Display for Selector {
96 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97 match self {
98 Self::OperationId(id) => f.write_str(id),
99 Self::MethodPath { method, path } => write!(f, "{method} {path}"),
100 Self::Tag(t) => write!(f, "tag:{t}"),
101 }
102 }
103}
104
105#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
106pub enum SelectorParseError {
107 #[error("selector is empty")]
108 Empty,
109 #[error("`tag:` selector has no tag name")]
110 EmptyTag,
111 #[error("selector `{0}` contains whitespace but is not a `METHOD /path` form")]
112 WhitespaceInOpId(String),
113}
114
115#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
117pub enum SelectorResolveError {
118 #[error(
119 "operationId `{requested}` is ambiguous after generator disambiguation: {matches}. Use a renamed ID shown above for renamed endpoints; select the unchanged `{requested}` endpoint with an exact `METHOD /path` selector"
120 )]
121 AmbiguousOperationId { requested: String, matches: String },
122 #[error(
123 "operationId `{requested}` was renamed to `{generated}` because its Rust name collides with another operation (`{method} {path}`). Use `{generated}` or the exact `METHOD /path` selector"
124 )]
125 RenamedOperationId {
126 requested: String,
127 generated: String,
128 method: String,
129 path: String,
130 },
131 #[error(
132 "no operation with id `{requested}`{}",
133 format_suggestion(.suggestion.as_deref())
134 )]
135 UnknownOperationId {
136 requested: String,
137 suggestion: Option<String>,
138 },
139 #[error(
140 "no operation at `{method} {path}`{}",
141 format_suggestion(.suggestion.as_deref())
142 )]
143 UnknownMethodPath {
144 method: String,
145 path: String,
146 suggestion: Option<String>,
147 },
148 #[error(
149 "no operations tagged `{requested}`{}",
150 format_suggestion(.suggestion.as_deref())
151 )]
152 UnknownTag {
153 requested: String,
154 suggestion: Option<String>,
155 },
156}
157
158fn format_suggestion(s: Option<&str>) -> String {
159 match s {
160 Some(s) => format!(". Did you mean `{s}`?"),
161 None => String::new(),
162 }
163}
164
165#[derive(Debug, Clone, Default)]
167pub struct Resolution {
168 pub operations: Vec<OperationSummary>,
170 pub duplicates: Vec<String>,
172}
173
174pub fn resolve(
179 selectors: &[Selector],
180 index: &OperationIndex,
181) -> Result<Resolution, SelectorResolveError> {
182 let mut out: Vec<OperationSummary> = Vec::new();
183 let mut duplicates: Vec<String> = Vec::new();
184 let mut seen_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
185
186 for sel in selectors {
187 match resolve_one(sel, index)? {
188 ResolvedOne::Single(op) => {
189 if seen_ids.insert(op.operation_id.clone()) {
190 out.push(op);
191 } else {
192 duplicates.push(sel.to_string());
193 }
194 }
195 ResolvedOne::Many(ops) => {
196 for op in ops {
197 if seen_ids.insert(op.operation_id.clone()) {
198 out.push(op);
199 } else {
200 duplicates.push(format!("{sel} → {}", op.operation_id));
201 }
202 }
203 }
204 }
205 }
206
207 Ok(Resolution {
208 operations: out,
209 duplicates,
210 })
211}
212
213enum ResolvedOne {
214 Single(OperationSummary),
215 Many(Vec<OperationSummary>),
216}
217
218fn resolve_one(
219 sel: &Selector,
220 index: &OperationIndex,
221) -> Result<ResolvedOne, SelectorResolveError> {
222 match sel {
223 Selector::OperationId(id) => {
224 if let Some(aliases) = index.operation_id_aliases(id) {
225 if aliases.len() > 1 {
226 let matches = aliases
227 .iter()
228 .filter_map(|generated| {
229 index
230 .operations()
231 .iter()
232 .find(|op| &op.operation_id == generated)
233 .map(|op| {
234 format!("`{} {}` → `{}`", op.method, op.path, op.operation_id)
235 })
236 })
237 .collect::<Vec<_>>()
238 .join(", ");
239 return Err(SelectorResolveError::AmbiguousOperationId {
240 requested: id.clone(),
241 matches,
242 });
243 }
244
245 if let Some(op) = index.operations().iter().find(|op| &op.operation_id == id) {
250 return Ok(ResolvedOne::Single(op.clone()));
251 }
252 if let Some(generated) = aliases.first()
253 && generated != id
254 && let Some(op) = index
255 .operations()
256 .iter()
257 .find(|op| &op.operation_id == generated)
258 {
259 return Err(SelectorResolveError::RenamedOperationId {
260 requested: id.clone(),
261 generated: generated.clone(),
262 method: op.method.clone(),
263 path: op.path.clone(),
264 });
265 }
266 }
267
268 index
269 .operations()
270 .iter()
271 .find(|op| &op.operation_id == id)
272 .cloned()
273 .map(ResolvedOne::Single)
274 .ok_or_else(|| SelectorResolveError::UnknownOperationId {
275 requested: id.clone(),
276 suggestion: suggest_op_id(id, index),
277 })
278 }
279 Selector::MethodPath { method, path } => index
280 .operations()
281 .iter()
282 .find(|op| &op.method == method && &op.path == path)
283 .cloned()
284 .map(ResolvedOne::Single)
285 .ok_or_else(|| SelectorResolveError::UnknownMethodPath {
286 method: method.clone(),
287 path: path.clone(),
288 suggestion: suggest_method_path(method, path, index),
289 }),
290 Selector::Tag(tag) => {
291 let matched: Vec<OperationSummary> = index
292 .operations()
293 .iter()
294 .filter(|op| op.tags.iter().any(|t| t == tag))
295 .cloned()
296 .collect();
297 if matched.is_empty() {
298 Err(SelectorResolveError::UnknownTag {
299 requested: tag.clone(),
300 suggestion: suggest_tag(tag, index),
301 })
302 } else {
303 Ok(ResolvedOne::Many(matched))
304 }
305 }
306 }
307}
308
309fn levenshtein(a: &str, b: &str) -> usize {
313 if a.is_empty() {
314 return b.chars().count();
315 }
316 if b.is_empty() {
317 return a.chars().count();
318 }
319 let a_chars: Vec<char> = a.chars().collect();
320 let b_chars: Vec<char> = b.chars().collect();
321 let m = a_chars.len();
322 let n = b_chars.len();
323
324 let mut prev: Vec<usize> = (0..=n).collect();
325 let mut curr: Vec<usize> = vec![0; n + 1];
326
327 for i in 1..=m {
328 curr[0] = i;
329 for j in 1..=n {
330 let cost = if a_chars[i - 1] == b_chars[j - 1] {
331 0
332 } else {
333 1
334 };
335 curr[j] = (prev[j] + 1).min(curr[j - 1] + 1).min(prev[j - 1] + cost);
336 }
337 std::mem::swap(&mut prev, &mut curr);
338 }
339 prev[n]
340}
341
342fn suggest_threshold(needle: &str) -> usize {
344 let len = needle.chars().count();
345 if len <= 4 {
346 1
347 } else if len <= 8 {
348 2
349 } else {
350 3
351 }
352}
353
354fn suggest_op_id(needle: &str, index: &OperationIndex) -> Option<String> {
355 let threshold = suggest_threshold(needle);
356 index
357 .operations()
358 .iter()
359 .filter(|op| !op.operation_id.is_empty())
360 .map(|op| {
361 (
362 op.operation_id.clone(),
363 levenshtein(needle, &op.operation_id),
364 )
365 })
366 .filter(|(_, d)| *d <= threshold)
367 .min_by_key(|(_, d)| *d)
368 .map(|(id, _)| id)
369}
370
371fn suggest_method_path(method: &str, path: &str, index: &OperationIndex) -> Option<String> {
372 let threshold = suggest_threshold(path);
373 let candidate = index
374 .operations()
375 .iter()
376 .filter(|op| op.method == method)
377 .map(|op| (op.path.clone(), levenshtein(path, &op.path)))
378 .filter(|(_, d)| *d <= threshold)
379 .min_by_key(|(_, d)| *d);
380 candidate.map(|(p, _)| format!("{method} {p}"))
381}
382
383fn suggest_tag(needle: &str, index: &OperationIndex) -> Option<String> {
384 let threshold = suggest_threshold(needle);
385 let mut best: Option<(String, usize)> = None;
386 let mut seen: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
387 for op in index.operations() {
388 for t in &op.tags {
389 if !seen.insert(t.as_str()) {
390 continue;
391 }
392 let d = levenshtein(needle, t);
393 if d <= threshold && best.as_ref().is_none_or(|(_, bd)| d < *bd) {
394 best = Some((t.clone(), d));
395 }
396 }
397 }
398 best.map(|(t, _)| format!("tag:{t}"))
399}
400
401#[cfg(test)]
402mod tests {
403 use super::*;
404 use crate::server::OperationSummary;
405
406 fn op(id: &str, method: &str, path: &str, tags: &[&str]) -> OperationSummary {
407 OperationSummary {
408 operation_id: id.to_string(),
409 method: method.to_string(),
410 path: path.to_string(),
411 tags: tags.iter().map(|s| s.to_string()).collect(),
412 supports_streaming: false,
413 }
414 }
415
416 fn idx(ops: Vec<OperationSummary>) -> OperationIndex {
417 OperationIndex::from_summaries(ops)
418 }
419
420 #[test]
423 fn parse_op_id() {
424 assert_eq!(
425 Selector::parse("createResponse").unwrap(),
426 Selector::OperationId("createResponse".into())
427 );
428 }
429
430 #[test]
431 fn parse_method_path_uppercases_verb() {
432 assert_eq!(
433 Selector::parse("post /v1/messages").unwrap(),
434 Selector::MethodPath {
435 method: "POST".into(),
436 path: "/v1/messages".into()
437 }
438 );
439 }
440
441 #[test]
442 fn parse_openapi_32_and_extension_methods() {
443 assert_eq!(
444 Selector::parse("query /v1/search").unwrap(),
445 Selector::MethodPath {
446 method: "QUERY".into(),
447 path: "/v1/search".into(),
448 }
449 );
450 assert_eq!(
451 Selector::parse("purge /cache").unwrap(),
452 Selector::MethodPath {
453 method: "PURGE".into(),
454 path: "/cache".into(),
455 }
456 );
457 }
458
459 #[test]
460 fn parse_tag() {
461 assert_eq!(
462 Selector::parse("tag:Chat").unwrap(),
463 Selector::Tag("Chat".into())
464 );
465 }
466
467 #[test]
468 fn parse_empty_errors() {
469 assert!(matches!(
470 Selector::parse(" "),
471 Err(SelectorParseError::Empty)
472 ));
473 assert!(matches!(
474 Selector::parse("tag:"),
475 Err(SelectorParseError::EmptyTag)
476 ));
477 }
478
479 #[test]
480 fn parse_whitespace_in_op_id_errors() {
481 assert!(matches!(
484 Selector::parse("foo bar"),
485 Err(SelectorParseError::WhitespaceInOpId(_))
486 ));
487 }
488
489 #[test]
490 fn parse_strips_whitespace() {
491 assert_eq!(
492 Selector::parse(" createResponse ").unwrap(),
493 Selector::OperationId("createResponse".into())
494 );
495 }
496
497 #[test]
500 fn resolve_op_id_hit() {
501 let i = idx(vec![op(
502 "createResponse",
503 "POST",
504 "/responses",
505 &["Responses"],
506 )]);
507 let r = resolve(&[Selector::OperationId("createResponse".into())], &i).unwrap();
508 assert_eq!(r.operations.len(), 1);
509 assert_eq!(r.operations[0].operation_id, "createResponse");
510 }
511
512 #[test]
513 fn resolve_op_id_miss_suggests() {
514 let i = idx(vec![op("createResponse", "POST", "/responses", &[])]);
515 let err = resolve(&[Selector::OperationId("createRespons".into())], &i).unwrap_err();
516 match err {
517 SelectorResolveError::UnknownOperationId { suggestion, .. } => {
518 assert_eq!(suggestion.as_deref(), Some("createResponse"));
519 }
520 _ => panic!("wrong variant"),
521 }
522 }
523
524 #[test]
525 fn duplicate_raw_operation_id_is_actionably_ambiguous() {
526 let mut aliases = std::collections::BTreeMap::new();
527 aliases.insert(
528 "foo".to_string(),
529 vec!["foo".to_string(), "foo_post".to_string()],
530 );
531 let i = idx(vec![
532 op("foo", "GET", "/first", &[]),
533 op("foo_post", "POST", "/second", &[]),
534 ])
535 .with_aliases(aliases);
536
537 let error = resolve(&[Selector::OperationId("foo".into())], &i).unwrap_err();
538 let message = error.to_string();
539 assert!(message.contains("ambiguous"));
540 assert!(message.contains("foo_post"));
541 assert!(message.contains("METHOD /path"));
542 }
543
544 #[test]
545 fn renamed_case_colliding_operation_id_reports_emitted_name() {
546 let mut aliases = std::collections::BTreeMap::new();
547 aliases.insert("Foo".to_string(), vec!["Foo_post".to_string()]);
548 let i = idx(vec![
549 op("foo", "GET", "/first", &[]),
550 op("Foo_post", "POST", "/second", &[]),
551 ])
552 .with_aliases(aliases);
553
554 let error = resolve(&[Selector::OperationId("Foo".into())], &i).unwrap_err();
555 let message = error.to_string();
556 assert!(message.contains("renamed to `Foo_post`"));
557 assert!(message.contains("POST /second"));
558
559 let resolved = resolve(&[Selector::OperationId("Foo_post".into())], &i).unwrap();
560 assert_eq!(resolved.operations[0].operation_id, "Foo_post");
561 }
562
563 #[test]
564 fn emitted_id_wins_when_it_matches_another_operations_raw_id() {
565 let mut aliases = std::collections::BTreeMap::new();
566 aliases.insert(
567 "foo".to_string(),
568 vec!["foo".to_string(), "foo_post".to_string()],
569 );
570 aliases.insert("foo_post".to_string(), vec!["foo_post_get".to_string()]);
571 let i = idx(vec![
572 op("foo", "GET", "/first", &[]),
573 op("foo_post", "POST", "/second", &[]),
574 op("foo_post_get", "GET", "/third", &[]),
575 ])
576 .with_aliases(aliases);
577
578 let resolved = resolve(&[Selector::OperationId("foo_post".into())], &i).unwrap();
579 assert_eq!(resolved.operations[0].path, "/second");
580 }
581
582 #[test]
583 fn resolve_method_path_hit() {
584 let i = idx(vec![op("m", "POST", "/v1/messages", &[])]);
585 let r = resolve(
586 &[Selector::MethodPath {
587 method: "POST".into(),
588 path: "/v1/messages".into(),
589 }],
590 &i,
591 )
592 .unwrap();
593 assert_eq!(r.operations.len(), 1);
594 }
595
596 #[test]
597 fn resolve_method_path_miss_suggests_same_method_only() {
598 let i = idx(vec![
599 op("a", "POST", "/v1/messages", &[]),
600 op("b", "GET", "/v1/messagex", &[]),
601 ]);
602 let err = resolve(
603 &[Selector::MethodPath {
604 method: "POST".into(),
605 path: "/v1/messagex".into(),
606 }],
607 &i,
608 )
609 .unwrap_err();
610 match err {
611 SelectorResolveError::UnknownMethodPath { suggestion, .. } => {
612 assert_eq!(suggestion.as_deref(), Some("POST /v1/messages"));
614 }
615 _ => panic!("wrong variant"),
616 }
617 }
618
619 #[test]
620 fn resolve_tag_expands() {
621 let i = idx(vec![
622 op("a", "GET", "/a", &["Files"]),
623 op("b", "POST", "/b", &["Files"]),
624 op("c", "POST", "/c", &["Chat"]),
625 ]);
626 let r = resolve(&[Selector::Tag("Files".into())], &i).unwrap();
627 assert_eq!(r.operations.len(), 2);
628 assert_eq!(r.operations[0].operation_id, "a");
629 assert_eq!(r.operations[1].operation_id, "b");
630 }
631
632 #[test]
633 fn resolve_tag_miss_suggests() {
634 let i = idx(vec![op("a", "GET", "/a", &["Embeddings"])]);
635 let err = resolve(&[Selector::Tag("Embedding".into())], &i).unwrap_err();
636 match err {
637 SelectorResolveError::UnknownTag { suggestion, .. } => {
638 assert_eq!(suggestion.as_deref(), Some("tag:Embeddings"));
639 }
640 _ => panic!("wrong variant"),
641 }
642 }
643
644 #[test]
645 fn resolve_dedup_within_run() {
646 let i = idx(vec![op("a", "GET", "/a", &["T"])]);
647 let r = resolve(
648 &[Selector::OperationId("a".into()), Selector::Tag("T".into())],
649 &i,
650 )
651 .unwrap();
652 assert_eq!(r.operations.len(), 1);
653 assert_eq!(r.duplicates.len(), 1);
654 assert!(r.duplicates[0].contains("tag:T"));
655 }
656
657 #[test]
658 fn resolve_preserves_input_order() {
659 let i = idx(vec![
660 op("a", "GET", "/a", &[]),
661 op("b", "POST", "/b", &[]),
662 op("c", "PUT", "/c", &[]),
663 ]);
664 let r = resolve(
665 &[
666 Selector::OperationId("c".into()),
667 Selector::OperationId("a".into()),
668 Selector::OperationId("b".into()),
669 ],
670 &i,
671 )
672 .unwrap();
673 let ids: Vec<&str> = r
674 .operations
675 .iter()
676 .map(|o| o.operation_id.as_str())
677 .collect();
678 assert_eq!(ids, ["c", "a", "b"]);
679 }
680
681 #[test]
682 fn levenshtein_basics() {
683 assert_eq!(levenshtein("", ""), 0);
684 assert_eq!(levenshtein("abc", "abc"), 0);
685 assert_eq!(levenshtein("abc", "abd"), 1);
686 assert_eq!(levenshtein("kitten", "sitting"), 3);
687 }
688}