Skip to main content

openapi_to_rust/server/
selector.rs

1//! Server operation selectors.
2//!
3//! Three forms (priority order in spec resolution):
4//!   1. `operationId`        — bare identifier
5//!   2. `METHOD /path`       — exact verb + path match
6//!   3. `tag:<name>`         — expands to every op with that tag
7//!
8//! Parsing is spec-independent; resolution requires an [`OperationIndex`].
9
10use super::{OperationIndex, OperationSummary};
11use std::fmt;
12
13/// One parsed selector. Construct via [`Selector::parse`].
14#[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    /// Parse a selector string. Whitespace is trimmed.
23    ///
24    /// Disambiguation:
25    /// - Starts with `tag:` → [`Selector::Tag`].
26    /// - Contains whitespace → split on first whitespace, treat as METHOD PATH.
27    /// - Otherwise → operationId.
28    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        // METHOD PATH form: detect by leading whitespace-separated method
43        // token followed by `/`-prefixed path.
44        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
68/// RFC 9110 method names are case-sensitive `token` values. OpenAPI 3.2 adds
69/// `QUERY` and permits arbitrary `additionalOperations`, so selector parsing
70/// must not hard-code the legacy eight verbs.
71fn 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/// Reason resolution failed, with a fuzzy-match suggestion when possible.
116#[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/// Outcome of resolving a list of selectors against an index.
166#[derive(Debug, Clone, Default)]
167pub struct Resolution {
168    /// Resolved operations in input order. Duplicates collapsed by operationId.
169    pub operations: Vec<OperationSummary>,
170    /// Duplicate selectors that resolved to an already-included operation.
171    pub duplicates: Vec<String>,
172}
173
174/// Resolve a list of parsed selectors against an [`OperationIndex`].
175/// Returns either every operation matched (in input order, deduped) or
176/// the first error encountered (fail-fast — codegen requires every
177/// selector to bind so unbound entries don't silently get dropped).
178pub 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                // A disambiguated generated ID can itself equal another
246                // operation's raw ID. Keep emitted IDs directly selectable;
247                // the raw-ID rename diagnostic only applies when there is no
248                // exact generated operation with this ID.
249                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
309/// Levenshtein distance between two short strings. Implementation
310/// uses two rolling rows to stay allocation-light for the small
311/// strings we compare (operationIds, paths, tags).
312fn 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
342/// Threshold scales with the longer string so long ids tolerate more typos.
343fn 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    // --- parser ---
421
422    #[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        // `foo bar` looks like METHOD PATH but `foo` isn't a verb and `bar`
482        // doesn't start with `/`, so it's an invalid op-id form.
483        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    // --- resolution ---
498
499    #[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                // Same-method nearest is /v1/messages (distance 1)
613                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}