1use std::fmt;
6
7#[cfg(feature = "document")]
8use http::Method;
9use serde::{Deserialize, Serialize};
10use thiserror::Error;
11
12#[derive(Debug, Clone, Error, PartialEq, Eq)]
14#[error("the {origin} `{raw}` does not kebab-case into [a-z0-9-]")]
15pub struct NameError {
16 pub origin: &'static str,
19 pub raw: String,
21}
22
23#[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 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#[cfg(feature = "document")]
94#[derive(Debug, Clone, Copy)]
95pub struct Grouping {
96 depth: usize,
97}
98
99#[cfg(feature = "document")]
100impl Grouping {
101 #[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 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 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#[cfg(feature = "document")]
141fn segments(path: &str) -> impl Iterator<Item = &str> {
142 path.split('/').filter(|segment| !segment.is_empty())
143}
144
145#[cfg(feature = "document")]
147fn templated(segment: &str) -> bool {
148 segment.contains('{')
149}
150
151#[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#[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#[cfg(feature = "document")]
195#[derive(Debug)]
196pub struct Namespace(Vec<String>);
197
198#[cfg(feature = "document")]
199impl Namespace {
200 #[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 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
226pub(crate) fn renamed(flag: &str, wire_name: &str) -> bool {
233 flag != kebab(wire_name)
234}
235
236#[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 #[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 #[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 #[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}