Skip to main content

typed_openapi/
names.rs

1//! The naming rules a command line forces on a document: where an operation
2//! sits in the two-level command tree, and what happens when two things in one
3//! operation want the same flag.
4
5use std::fmt;
6
7#[cfg(feature = "document")]
8use http::Method;
9use serde::{Deserialize, Serialize};
10use thiserror::Error;
11
12/// A name the document offers that is not spellable as a command.
13#[derive(Debug, Clone, Error, PartialEq, Eq)]
14#[error("the {origin} `{raw}` does not kebab-case into [a-z0-9-]")]
15pub struct NameError {
16    /// What the name was read off, so the message says where to go and look:
17    /// a path segment, or the `x-cli-` marker that overrode it.
18    pub origin: &'static str,
19    /// The name, as the document spells it.
20    pub raw: String,
21}
22
23/// One half of a command name: kebab-cased, `[a-z0-9-]` only.
24///
25/// A command is two of these, `<group> <command>` — `vouchers update`. Which
26/// path segment each half is taken from is the grouping rule a bless step
27/// runs; this type is only what the user types.
28///
29/// A bless step writes both into its reduced model, so a name comes back off a
30/// blob as well as out of a document. Both doors are the same door: `serde`
31/// reads it as a `String` and runs it through [`CommandName::new`], so a blob
32/// cannot smuggle in a name a document could not have produced.
33#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
34#[serde(try_from = "String", into = "String")]
35pub struct CommandName(String);
36
37impl CommandName {
38    /// `renderVoucher` becomes `render-voucher`; anything that will not reduce to
39    /// `[a-z0-9-]` is rejected rather than mangled.
40    ///
41    /// `origin` is what the raw name was read off, and it is there for the
42    /// error: a document whose own words cannot be spelled is told which word.
43    pub fn new(origin: &'static str, raw: &str) -> Result<Self, NameError> {
44        let name = kebab(raw);
45        let allowed = |b: u8| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-';
46        if name.is_empty() || !name.bytes().all(allowed) {
47            return Err(NameError {
48                origin,
49                raw: raw.to_owned(),
50            });
51        }
52        Ok(Self(name))
53    }
54
55    #[must_use]
56    pub fn as_str(&self) -> &str {
57        &self.0
58    }
59}
60
61impl TryFrom<String> for CommandName {
62    type Error = NameError;
63
64    fn try_from(raw: String) -> Result<Self, NameError> {
65        Self::new("reduced model", &raw)
66    }
67}
68
69impl From<CommandName> for String {
70    fn from(name: CommandName) -> Self {
71        name.0
72    }
73}
74
75impl fmt::Display for CommandName {
76    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77        f.write_str(&self.0)
78    }
79}
80
81/// Where each of a document's operations sits in the command tree.
82///
83/// *Requires the `document` feature.*
84///
85/// The group is a path segment, and which one is a fact about the whole
86/// document: the first, unless every operation shares it — a document served
87/// entirely under `/v1` must not collapse into one group named `v1`. So the
88/// rule is read off every path once, with [`Grouping::of`], and then applied
89/// to one path at a time.
90///
91/// Grouping is bless-time work. Both names travel in the reduced model, so a
92/// shipped binary reads them off its blob and never runs this rule at all.
93#[cfg(feature = "document")]
94#[derive(Debug, Clone, Copy)]
95pub struct Grouping {
96    depth: usize,
97}
98
99#[cfg(feature = "document")]
100impl Grouping {
101    /// Read the rule off every path the document declares.
102    ///
103    /// Descend while every path says the same thing — a segment they all share
104    /// tells nothing apart — and stop at the first segment that distinguishes
105    /// them, or before a path parameter, which names a value rather than a
106    /// resource.
107    #[must_use]
108    pub fn of(paths: &[&str]) -> Self {
109        let mut depth = 0;
110        while descends(paths, depth) {
111            depth += 1;
112        }
113        Self { depth }
114    }
115
116    /// The group `path` belongs to.
117    pub fn group(&self, path: &str) -> Result<CommandName, NameError> {
118        CommandName::new(
119            "path segment",
120            segments(path).nth(self.depth).unwrap_or(path),
121        )
122    }
123
124    /// What one operation is called under its group: the last literal segment
125    /// below the group, or — where the path has none left to spend — whatever
126    /// its method makes of it.
127    pub fn leaf(&self, path: &str, method: &Method) -> Result<CommandName, NameError> {
128        match segments(path)
129            .skip(self.depth + 1)
130            .filter(|segment| !templated(segment))
131            .last()
132        {
133            Some(last) => CommandName::new("path segment", last),
134            None => CommandName::new("method", verb(method, segments(path).any(templated))),
135        }
136    }
137}
138
139/// A path's segments: `/vouchers/{id}/render` is three.
140#[cfg(feature = "document")]
141fn segments(path: &str) -> impl Iterator<Item = &str> {
142    path.split('/').filter(|segment| !segment.is_empty())
143}
144
145/// A segment the caller fills in, `{id}`.
146#[cfg(feature = "document")]
147fn templated(segment: &str) -> bool {
148    segment.contains('{')
149}
150
151/// Is the group one segment further down than `depth`?
152///
153/// Only when every path says the same thing at `depth`, and every one of them
154/// has a literal segment below it to be grouped by instead.
155#[cfg(feature = "document")]
156fn descends(paths: &[&str], depth: usize) -> bool {
157    let Some(shared) = paths.first().and_then(|path| segments(path).nth(depth)) else {
158        return false;
159    };
160    paths.iter().all(|path| {
161        let mut below = segments(path).skip(depth);
162        below.next() == Some(shared) && below.next().is_some_and(|next| !templated(next))
163    })
164}
165
166/// What an operation whose path has nothing left to say is called.
167///
168/// A safe method reads: one resource where the path addresses one, the
169/// collection where it does not. Everything else is named for what it does.
170#[cfg(feature = "document")]
171fn verb(method: &Method, addressed: bool) -> &'static str {
172    match *method {
173        Method::POST => "create",
174        Method::PUT => "update",
175        Method::PATCH => "patch",
176        Method::DELETE => "delete",
177        Method::HEAD => "head",
178        Method::OPTIONS => "options",
179        Method::TRACE => "trace",
180        _ if addressed => "get",
181        _ => "list",
182    }
183}
184
185/// The flag names one subcommand has already spent.
186///
187/// *Requires the `document` feature.*
188///
189/// `PUT /vouchers/{id}` takes an `id` in the path and an `id` in the body; clap
190/// panics on a duplicate name, so the second claimant is prefixed rather than
191/// dropped. Both values stay reachable from the command line, and the flag that
192/// moved says which wire name it carries in its help line — `renamed` is how
193/// a flag reports that once the document it came from is gone.
194#[cfg(feature = "document")]
195#[derive(Debug)]
196pub struct Namespace(Vec<String>);
197
198#[cfg(feature = "document")]
199impl Namespace {
200    /// Start with the CLI's own flags already spent, so a document that happens
201    /// to name a field `commit` renames rather than colliding at startup.
202    #[must_use]
203    pub fn with_reserved<const N: usize>(reserved: [&str; N]) -> Self {
204        Self(reserved.iter().map(|s| (*s).to_owned()).collect())
205    }
206
207    /// The flag to use: `preferred` when it is free, otherwise prefixed.
208    pub fn claim(&mut self, preferred: &str, prefix: &str) -> String {
209        let mut candidate = preferred.to_owned();
210        let mut renamed = false;
211        let mut suffix = 2;
212        while self.0.iter().any(|taken| taken == &candidate) {
213            candidate = if renamed {
214                format!("{prefix}-{preferred}-{suffix}")
215            } else {
216                format!("{prefix}-{preferred}")
217            };
218            renamed = true;
219            suffix += 1;
220        }
221        self.0.push(candidate.clone());
222        candidate
223    }
224}
225
226/// Did this flag have to move aside from the plain kebab-case of its wire name?
227///
228/// `Namespace::claim` prefixes a flag whose preferred name the subcommand has
229/// already spent, and a flag that moved has to say which wire name it carries.
230/// A parameter and a body field are both flags with wire names, so the rule
231/// lives here rather than once in each of them.
232pub(crate) fn renamed(flag: &str, wire_name: &str) -> bool {
233    flag != kebab(wire_name)
234}
235
236/// `createVoucher` and `internal_ref` both become flag-shaped: lowercase words
237/// joined by `-`. Characters outside `[A-Za-z0-9]` are separators.
238#[must_use]
239pub fn kebab(name: &str) -> String {
240    let mut out = String::with_capacity(name.len() + 4);
241    let mut chars = name.chars().peekable();
242    let mut prev_lower_or_digit = false;
243    while let Some(c) = chars.next() {
244        if c.is_ascii_uppercase() {
245            let starts_word =
246                prev_lower_or_digit || chars.peek().is_some_and(char::is_ascii_lowercase);
247            if starts_word && !out.is_empty() && !out.ends_with('-') {
248                out.push('-');
249            }
250            out.push(c.to_ascii_lowercase());
251            prev_lower_or_digit = false;
252        } else if c.is_ascii_alphanumeric() {
253            out.push(c);
254            prev_lower_or_digit = true;
255        } else {
256            if !out.is_empty() && !out.ends_with('-') {
257                out.push('-');
258            }
259            prev_lower_or_digit = false;
260        }
261    }
262    out.trim_matches('-').to_owned()
263}
264
265#[cfg(test)]
266#[expect(
267    clippy::expect_used,
268    reason = "a test that cannot build its fixture should fail loudly and name it"
269)]
270mod tests {
271    use super::*;
272
273    #[test]
274    fn names_kebab_case_into_the_allowed_alphabet() {
275        for (raw, want) in [
276            ("createVoucher", "create-voucher"),
277            ("uploadDocumentMultipart", "upload-document-multipart"),
278            ("getHTTPStatus", "get-http-status"),
279            ("internal_ref", "internal-ref"),
280            ("VoucherLineItem", "voucher-line-item"),
281            ("Already-Kebab", "already-kebab"),
282        ] {
283            let name = CommandName::new("path segment", raw);
284            assert_eq!(name.map(|n| n.as_str().to_owned()), Ok(want.to_owned()));
285        }
286    }
287
288    #[test]
289    fn a_name_with_nothing_to_kebab_is_rejected_and_says_where_it_came_from() {
290        assert!(CommandName::new("path segment", "").is_err());
291        let error = CommandName::new("x-cli-command", "___").expect_err("nothing to kebab");
292        assert_eq!(
293            error.to_string(),
294            "the x-cli-command `___` does not kebab-case into [a-z0-9-]"
295        );
296    }
297
298    #[cfg(feature = "document")]
299    #[test]
300    fn the_group_is_the_segment_that_first_tells_operations_apart() {
301        let grouping = Grouping::of(&["/vouchers", "/vouchers/{id}", "/contacts"]);
302        let group = |path| {
303            grouping
304                .group(path)
305                .map(|name| name.as_str().to_owned())
306                .expect("a spellable segment")
307        };
308        assert_eq!(group("/vouchers/{id}"), "vouchers");
309        assert_eq!(group("/contacts"), "contacts");
310    }
311
312    /// A document served entirely under one prefix must not collapse into one
313    /// group named for the prefix.
314    #[cfg(feature = "document")]
315    #[test]
316    fn a_shared_prefix_is_descended_past() {
317        let grouping = Grouping::of(&["/v1/vouchers", "/v1/vouchers/{id}", "/v1/contacts"]);
318        let group = |path| {
319            grouping
320                .group(path)
321                .map(|name| name.as_str().to_owned())
322                .expect("a spellable segment")
323        };
324        assert_eq!(group("/v1/vouchers/{id}"), "vouchers");
325        assert_eq!(group("/v1/contacts"), "contacts");
326    }
327
328    /// Descending stops before a path parameter: `{tenant}` names a value the
329    /// user supplies, not a resource to group by.
330    #[cfg(feature = "document")]
331    #[test]
332    fn descending_stops_before_a_path_parameter() {
333        let grouping = Grouping::of(&["/v1/{tenant}/vouchers", "/v1/{tenant}/contacts"]);
334        assert_eq!(
335            grouping
336                .group("/v1/{tenant}/vouchers")
337                .map(|name| name.as_str().to_owned()),
338            Ok("v1".to_owned())
339        );
340    }
341
342    #[cfg(feature = "document")]
343    #[test]
344    fn a_leaf_is_the_last_literal_segment_below_the_group() {
345        let grouping = Grouping::of(&["/vouchers/{id}/render", "/contacts"]);
346        assert_eq!(
347            grouping
348                .leaf("/vouchers/{id}/render", &Method::GET)
349                .map(|name| name.as_str().to_owned()),
350            Ok("render".to_owned())
351        );
352    }
353
354    /// Where the path has no segment left to spend, the method names the
355    /// operation — and a safe method says which of the two reads it is.
356    #[cfg(feature = "document")]
357    #[test]
358    fn a_path_with_nothing_left_to_say_is_named_by_its_method() {
359        let grouping = Grouping::of(&["/vouchers", "/vouchers/{id}"]);
360        let leaf = |path, method: Method| {
361            grouping
362                .leaf(path, &method)
363                .map(|name| name.as_str().to_owned())
364                .expect("a method always spells one")
365        };
366        assert_eq!(leaf("/vouchers", Method::GET), "list");
367        assert_eq!(leaf("/vouchers/{id}", Method::GET), "get");
368        assert_eq!(leaf("/vouchers", Method::POST), "create");
369        assert_eq!(leaf("/vouchers/{id}", Method::PUT), "update");
370        assert_eq!(leaf("/vouchers/{id}", Method::PATCH), "patch");
371        assert_eq!(leaf("/vouchers/{id}", Method::DELETE), "delete");
372    }
373
374    #[cfg(feature = "document")]
375    #[test]
376    fn a_body_field_moves_aside_for_a_parameter_of_the_same_name() {
377        let mut flags = Namespace::with_reserved(["commit", "json-body"]);
378        assert_eq!(flags.claim("id", "param"), "id");
379        assert_eq!(flags.claim("id", "body"), "body-id");
380        assert_eq!(flags.claim("id", "body"), "body-id-3");
381        assert_eq!(flags.claim("commit", "body"), "body-commit");
382    }
383}