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('/') {
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/// Reason resolution failed, with a fuzzy-match suggestion when possible.
97#[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/// Outcome of resolving a list of selectors against an index.
134#[derive(Debug, Clone, Default)]
135pub struct Resolution {
136    /// Resolved operations in input order. Duplicates collapsed by operationId.
137    pub operations: Vec<OperationSummary>,
138    /// Duplicate selectors that resolved to an already-included operation.
139    pub duplicates: Vec<String>,
140}
141
142/// Resolve a list of parsed selectors against an [`OperationIndex`].
143/// Returns either every operation matched (in input order, deduped) or
144/// the first error encountered (fail-fast — codegen requires every
145/// selector to bind so unbound entries don't silently get dropped).
146pub 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
231/// Levenshtein distance between two short strings. Implementation
232/// uses two rolling rows to stay allocation-light for the small
233/// strings we compare (operationIds, paths, tags).
234fn 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
264/// Threshold scales with the longer string so long ids tolerate more typos.
265fn 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    // --- parser ---
343
344    #[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        // `foo bar` looks like METHOD PATH but `foo` isn't a verb and `bar`
386        // doesn't start with `/`, so it's an invalid op-id form.
387        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    // --- resolution ---
402
403    #[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                // Same-method nearest is /v1/messages (distance 1)
459                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}