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 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#[cfg(feature = "document")]
86#[derive(Debug, Clone, Copy)]
87pub struct Grouping {
88 depth: usize,
89}
90
91#[cfg(feature = "document")]
92impl Grouping {
93 #[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 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 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#[cfg(feature = "document")]
133fn segments(path: &str) -> impl Iterator<Item = &str> {
134 path.split('/').filter(|segment| !segment.is_empty())
135}
136
137#[cfg(feature = "document")]
139fn templated(segment: &str) -> bool {
140 segment.contains('{')
141}
142
143#[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#[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#[cfg(feature = "document")]
187#[derive(Debug)]
188pub struct Namespace(Vec<String>);
189
190#[cfg(feature = "document")]
191impl Namespace {
192 #[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 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
223pub(crate) fn renamed(flag: &str, wire_name: &str) -> bool {
230 flag != kebab(wire_name)
231}
232
233pub(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#[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 #[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 #[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 #[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}