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