server_less_core/lib.rs
1//! Core traits and types for server-less.
2//!
3//! This crate provides the foundational types that server-less macros generate code against.
4
5pub mod error;
6pub mod extract;
7#[cfg(feature = "config")]
8pub mod config;
9
10/// Re-export of `toml` for use by `#[derive(Config)]`-generated code.
11///
12/// Generated serde-nested deserialization code references `toml::de::Error`
13/// via this path so callers don't need to add `toml` to their own dependencies.
14#[cfg(feature = "config")]
15#[doc(hidden)]
16pub use toml as __toml;
17
18pub use error::{
19 ErrorCode, ErrorResponse, HttpStatusFallback, HttpStatusHelper, IntoErrorCode,
20 SchemaValidationError,
21};
22pub use extract::Context;
23
24#[cfg(feature = "ws")]
25pub use extract::WsSender;
26
27/// One node in a CLI "manual": the reference entry for a single command path.
28///
29/// The manual is the whole-subtree aggregate emitted by the `--manual` flag.
30/// Each leaf command in the tree contributes one `CliManualNode`; mount points
31/// contribute the nodes of their subtree (recursively), with the path prefixed.
32///
33/// This is a serializable data structure (not a closure or scraped runtime
34/// state), so the manual caches, transports, and diffs cleanly. The `--manual`
35/// rendering layer turns a `Vec<CliManualNode>` into either human-readable text
36/// or a path-keyed JSON object.
37#[cfg(feature = "cli")]
38#[derive(Clone, Debug, serde::Serialize)]
39pub struct CliManualNode {
40 /// Space-separated command path from the root of the *invoked* subtree,
41 /// e.g. `"edit history list"`. The root subtree's own node (if it has a
42 /// default action) uses the empty string.
43 pub path: String,
44 /// The command's description (first paragraph of its doc comment), if any.
45 pub description: Option<String>,
46 /// JSON Schema of the command's input parameters.
47 pub input_schema: serde_json::Value,
48 /// JSON Schema of the command's return type.
49 pub output_schema: serde_json::Value,
50}
51
52/// Render a flat list of manual nodes as a path-keyed JSON object.
53///
54/// This is the structured (`--manual --json` / `--jq`) shape: one key per
55/// command path, each value carrying `description`, `input_schema`, and
56/// `output_schema`. Used by `#[cli]`-generated `--manual` handling.
57#[cfg(feature = "cli")]
58pub fn cli_manual_to_json(nodes: &[CliManualNode]) -> serde_json::Value {
59 let mut map = serde_json::Map::new();
60 for node in nodes {
61 map.insert(
62 node.path.clone(),
63 serde_json::json!({
64 "description": node.description,
65 "input_schema": node.input_schema,
66 "output_schema": node.output_schema,
67 }),
68 );
69 }
70 serde_json::Value::Object(map)
71}
72
73/// Render a flat list of manual nodes as human-readable reference text.
74///
75/// This is the default (`--manual` with no format flag) shape: a markdown-ish
76/// reference page, one section per command path. Used by `#[cli]`-generated
77/// `--manual` handling.
78#[cfg(feature = "cli")]
79pub fn cli_manual_to_text(nodes: &[CliManualNode]) -> String {
80 let mut out = String::new();
81 for (i, node) in nodes.iter().enumerate() {
82 if i > 0 {
83 out.push('\n');
84 }
85 let heading = if node.path.is_empty() {
86 "(default)"
87 } else {
88 node.path.as_str()
89 };
90 out.push_str(&format!("# {heading}\n"));
91 if let Some(desc) = &node.description
92 && !desc.is_empty()
93 {
94 out.push_str(&format!("\n{desc}\n"));
95 }
96 if let Some(props) = node
97 .input_schema
98 .get("properties")
99 .and_then(|p| p.as_object())
100 && !props.is_empty()
101 {
102 out.push_str("\nParameters:\n");
103 let required: std::collections::HashSet<&str> = node
104 .input_schema
105 .get("required")
106 .and_then(|r| r.as_array())
107 .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
108 .unwrap_or_default();
109 for (name, schema) in props {
110 let ty = schema
111 .get("type")
112 .and_then(|t| t.as_str())
113 .unwrap_or("value");
114 let req = if required.contains(name.as_str()) {
115 " (required)"
116 } else {
117 ""
118 };
119 out.push_str(&format!(" {name}: {ty}{req}\n"));
120 }
121 }
122 }
123 out
124}
125
126/// Trait for types that can be mounted as CLI subcommand groups.
127///
128/// Implemented automatically by `#[cli]` on an impl block. Allows nested
129/// composition: a parent CLI can mount a child's commands as a subcommand group.
130#[cfg(feature = "cli")]
131pub trait CliSubcommand {
132 /// Build the clap Command tree for this type's subcommands.
133 fn cli_command() -> ::clap::Command;
134
135 /// Collect the manual (whole-subtree reference) nodes for this type.
136 ///
137 /// `prefix` is the command path of *this* node from the root of the invoked
138 /// subtree (empty at the invocation root). Each leaf appends one node; each
139 /// mount point recurses into its child type with an extended prefix.
140 ///
141 /// The default returns an empty vec so hand-written `CliSubcommand` impls
142 /// keep compiling; `#[cli]` always overrides it.
143 fn cli_manual_nodes(&self, _prefix: &str) -> Vec<CliManualNode> {
144 Vec::new()
145 }
146
147 /// Dispatch a matched subcommand to the appropriate method.
148 fn cli_dispatch(&self, matches: &::clap::ArgMatches) -> Result<(), Box<dyn std::error::Error>>;
149
150 /// Dispatch a matched subcommand asynchronously.
151 ///
152 /// Awaits the dispatched method directly without creating an internal runtime.
153 /// Used by `cli_run_async` to support user-provided async runtimes.
154 fn cli_dispatch_async<'a>(
155 &'a self,
156 matches: &'a ::clap::ArgMatches,
157 ) -> impl std::future::Future<Output = Result<(), Box<dyn std::error::Error>>> + 'a {
158 async move { self.cli_dispatch(matches) }
159 }
160}
161
162/// Sink for `#[cli(global = [...])]` flag values.
163///
164/// When an impl declares `#[cli(global = [...])]`, the generated dispatch delivers
165/// each declared global flag's value to this trait — once per flag, before the matched
166/// method runs. The macro emits a `Self: CliGlobals` bound (by actually *calling*
167/// [`CliGlobals::set_global_flag`] in the dispatch code), so declaring `global` without
168/// implementing `CliGlobals` is a **compile error**. The flag can never be
169/// advertised-but-silently-inert: delivery is generated, and the sink it delivers to is
170/// named, so its absence is loud.
171///
172/// There is deliberately **no blanket default impl** (`impl<T> CliGlobals for T`). A
173/// default no-op would itself be a silent sink — it would satisfy the generated bound
174/// while doing nothing, re-creating the exact footgun the bound exists to prevent. Every
175/// service that declares `global` must implement this trait explicitly. See
176/// `docs/design/cli-capability-wiring-invariant.md`.
177///
178/// # Receiving values
179///
180/// `set_global_flag` takes `&self`, so a service that needs to stash the value uses
181/// interior mutability (e.g. `Cell<bool>`). TTY/config/root-aware resolution policy
182/// lives in this one method, written once per service — not duplicated into every
183/// command body.
184///
185/// ```ignore
186/// use std::cell::Cell;
187/// use server_less::{cli, CliGlobals};
188///
189/// #[derive(Default)]
190/// struct App { verbose: Cell<bool> }
191///
192/// impl CliGlobals for App {
193/// fn set_global_flag(&self, name: &str, value: bool) {
194/// if name == "verbose" { self.verbose.set(value); }
195/// }
196/// }
197///
198/// #[cli(name = "app", global = [verbose])]
199/// impl App {
200/// /// Do the thing
201/// fn run(&self) {
202/// if self.verbose.get() { eprintln!("[verbose]"); }
203/// }
204/// }
205/// ```
206#[cfg(feature = "cli")]
207pub trait CliGlobals {
208 /// Receive one declared global flag's parsed value.
209 ///
210 /// `name` is the flag as it appears on the command line — kebab-case, matching the
211 /// `--long` form (e.g. a `dry_run` global is delivered as `"dry-run"`). Called once
212 /// per declared global flag, before the matched method runs.
213 fn set_global_flag(&self, name: &str, value: bool);
214}
215
216/// Trait for types that can be mounted as MCP tool namespaces.
217///
218/// Implemented automatically by `#[mcp]` on an impl block. Allows nested
219/// composition: a parent MCP server can mount a child's tools with a name prefix.
220#[cfg(feature = "mcp")]
221pub trait McpNamespace {
222 /// Get tool definitions for this namespace.
223 fn mcp_namespace_tools() -> Vec<serde_json::Value>;
224
225 /// Get tool names for this namespace (without prefix).
226 fn mcp_namespace_tool_names() -> Vec<String>;
227
228 /// Call a tool by name (sync). Returns error for async-only methods.
229 fn mcp_namespace_call(
230 &self,
231 name: &str,
232 args: serde_json::Value,
233 ) -> Result<serde_json::Value, String>;
234
235 /// Call a tool by name (async).
236 fn mcp_namespace_call_async(
237 &self,
238 name: &str,
239 args: serde_json::Value,
240 ) -> impl std::future::Future<Output = Result<serde_json::Value, String>> + Send;
241}
242
243/// Trait for types that can be mounted as JSON-RPC method namespaces.
244///
245/// Implemented automatically by `#[jsonrpc]` on an impl block. Allows nested
246/// composition: a parent JSON-RPC server can mount a child's methods with a dot-separated prefix.
247#[cfg(feature = "jsonrpc")]
248pub trait JsonRpcMount {
249 /// Get method names for this mount (without prefix).
250 fn jsonrpc_mount_methods() -> Vec<String>;
251
252 /// Dispatch a method call (sync). Returns error for async-only methods.
253 fn jsonrpc_mount_dispatch(
254 &self,
255 method: &str,
256 params: serde_json::Value,
257 ) -> Result<serde_json::Value, String>;
258
259 /// Dispatch a method call (async).
260 fn jsonrpc_mount_dispatch_async(
261 &self,
262 method: &str,
263 params: serde_json::Value,
264 ) -> impl std::future::Future<Output = Result<serde_json::Value, String>> + Send;
265}
266
267/// Trait for types that can be mounted as WebSocket method namespaces.
268///
269/// Implemented automatically by `#[ws]` on an impl block. Allows nested
270/// composition: a parent WebSocket server can mount a child's methods with a dot-separated prefix.
271#[cfg(feature = "ws")]
272pub trait WsMount {
273 /// Get method names for this mount (without prefix).
274 fn ws_mount_methods() -> Vec<String>;
275
276 /// Dispatch a method call (sync). Returns error for async-only methods.
277 fn ws_mount_dispatch(
278 &self,
279 method: &str,
280 params: serde_json::Value,
281 ) -> Result<serde_json::Value, String>;
282
283 /// Dispatch a method call (async).
284 fn ws_mount_dispatch_async(
285 &self,
286 method: &str,
287 params: serde_json::Value,
288 ) -> impl std::future::Future<Output = Result<serde_json::Value, String>> + Send;
289}
290
291/// Trait for types that can be mounted as HTTP route groups.
292///
293/// Implemented automatically by `#[http]` on an impl block. Allows nested
294/// composition: a parent HTTP server can mount a child's routes under a path prefix.
295#[cfg(feature = "http")]
296pub trait HttpMount: Send + Sync + 'static {
297 /// Build an axum Router for this mount's routes.
298 fn http_mount_router(self: ::std::sync::Arc<Self>) -> ::axum::Router;
299
300 /// Get full OpenAPI path definitions for this mount (including any nested mounts).
301 ///
302 /// Paths are relative to the mount point. The parent prefixes them when composing.
303 fn http_mount_openapi_paths() -> Vec<server_less_openapi::OpenApiPath>
304 where
305 Self: Sized;
306}
307
308/// Format a `serde_json::Value` according to the active JSON output flag.
309///
310/// This function is only called when at least one JSON flag is active
311/// (`--json`, `--jsonl`, or `--jq`). The overall CLI default output path
312/// (human-readable `Display` text) is generated directly by the `#[cli]`
313/// macro and does not go through this function.
314///
315/// Flag precedence (first match wins):
316///
317/// - `jq`: filter the value using jaq (jq implemented in Rust, no external binary needed)
318/// - `jsonl`: one compact JSON object per line (arrays are unwrapped; non-arrays emit a single line)
319/// - `json` (`true`): compact JSON — `serde_json::to_string`, no whitespace
320/// - `json` (`false`) with no other flag active: pretty-printed JSON — only reachable when called
321/// directly, not from `#[cli]`-generated code (which always sets at least one flag)
322#[cfg(feature = "cli")]
323pub fn cli_format_output(
324 value: serde_json::Value,
325 jsonl: bool,
326 json: bool,
327 jq: Option<&str>,
328) -> Result<String, Box<dyn std::error::Error>> {
329 if let Some(filter) = jq {
330 use jaq_core::load::{Arena, File as JaqFile, Loader};
331 use jaq_core::{Compiler, Ctx, Vars, data, unwrap_valr};
332 use jaq_json::Val;
333
334 let loader =
335 Loader::new(jaq_core::defs().chain(jaq_std::defs()).chain(jaq_json::defs()));
336 let arena = Arena::default();
337
338 let program = JaqFile {
339 code: filter,
340 path: (),
341 };
342
343 let modules = loader
344 .load(&arena, program)
345 .map_err(|errs| format!("jq parse error: {:?}", errs))?;
346
347 let filter_compiled = Compiler::default()
348 .with_funs(jaq_core::funs().chain(jaq_std::funs()).chain(jaq_json::funs()))
349 .compile(modules)
350 .map_err(|errs| format!("jq compile error: {:?}", errs))?;
351
352 let val: Val = serde_json::from_value(value)?;
353 let ctx = Ctx::<data::JustLut<Val>>::new(&filter_compiled.lut, Vars::new([]));
354 let out = filter_compiled.id.run((ctx, val)).map(unwrap_valr);
355
356 let mut results = Vec::new();
357 for result in out {
358 match result {
359 Ok(v) => results.push(v.to_string()),
360 Err(e) => return Err(format!("jq runtime error: {:?}", e).into()),
361 }
362 }
363
364 Ok(results.join("\n"))
365 } else if jsonl {
366 match value {
367 serde_json::Value::Array(items) => {
368 let lines: Vec<String> = items
369 .iter()
370 .map(serde_json::to_string)
371 .collect::<Result<_, _>>()?;
372 Ok(lines.join("\n"))
373 }
374 other => Ok(serde_json::to_string(&other)?),
375 }
376 } else if json {
377 Ok(serde_json::to_string(&value)?)
378 } else {
379 Ok(serde_json::to_string_pretty(&value)?)
380 }
381}
382
383/// Generate a JSON Schema for a type at runtime using schemars.
384///
385/// Called by `--output-schema` in `#[cli]`-generated code when the `jsonschema`
386/// feature is enabled. Users must `#[derive(schemars::JsonSchema)]` on their
387/// return types to use this.
388#[cfg(feature = "jsonschema")]
389pub fn cli_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
390 serde_json::to_value(schemars::schema_for!(T))
391 .unwrap_or_else(|_| serde_json::json!({"type": "object"}))
392}
393
394/// A clap [`TypedValueParser`] that uses [`schemars::JsonSchema`] to surface
395/// enum variants as possible values, and [`std::str::FromStr`] for actual parsing.
396///
397/// When `T` is an enum deriving `JsonSchema`, its variants appear in `--help`
398/// output and clap's error messages with no extra derives on the user type.
399/// For non-enum types (e.g. `String`, `u32`), this is a transparent pass-through
400/// to `FromStr`.
401///
402/// Used automatically by `#[cli]`-generated code when both the `cli` and
403/// `jsonschema` features are enabled.
404#[cfg(all(feature = "cli", feature = "jsonschema"))]
405#[derive(Clone)]
406pub struct SchemaValueParser<T: Clone + Send + Sync + 'static> {
407 /// Enum variant names as `'static` str. We leak each string once at
408 /// parser-construction time (command build, not per-parse), which is
409 /// acceptable for a CLI binary: the leak is bounded (a few bytes per
410 /// variant) and the memory is reclaimed when the process exits.
411 variants: Option<std::sync::Arc<[&'static str]>>,
412 _marker: std::marker::PhantomData<T>,
413}
414
415#[cfg(all(feature = "cli", feature = "jsonschema"))]
416impl<T> Default for SchemaValueParser<T>
417where
418 T: schemars::JsonSchema + std::str::FromStr + Clone + Send + Sync + 'static,
419{
420 fn default() -> Self {
421 Self::new()
422 }
423}
424
425#[cfg(all(feature = "cli", feature = "jsonschema"))]
426impl<T> SchemaValueParser<T>
427where
428 T: schemars::JsonSchema + std::str::FromStr + Clone + Send + Sync + 'static,
429{
430 /// Creates a new `SchemaValueParser`, extracting allowed enum variants if available.
431 pub fn new() -> Self {
432 let variants = extract_enum_variants::<T>().map(|strings| {
433 let leaked: Vec<&'static str> = strings
434 .into_iter()
435 .map(|s| Box::leak(s.into_boxed_str()) as &'static str)
436 .collect();
437 leaked.into()
438 });
439 Self {
440 variants,
441 _marker: std::marker::PhantomData,
442 }
443 }
444}
445
446#[cfg(all(feature = "cli", feature = "jsonschema"))]
447fn extract_enum_variants<T: schemars::JsonSchema>() -> Option<Vec<String>> {
448 let schema_value = serde_json::to_value(schemars::schema_for!(T)).ok()?;
449 let enum_values = schema_value.get("enum")?.as_array()?;
450 let variants: Vec<String> = enum_values
451 .iter()
452 .filter_map(|v| v.as_str().map(String::from))
453 .collect();
454 if variants.is_empty() {
455 None
456 } else {
457 Some(variants)
458 }
459}
460
461#[cfg(all(feature = "cli", feature = "jsonschema"))]
462impl<T> ::clap::builder::TypedValueParser for SchemaValueParser<T>
463where
464 T: schemars::JsonSchema + std::str::FromStr + Clone + Send + Sync + 'static,
465 <T as std::str::FromStr>::Err: std::fmt::Display,
466{
467 type Value = T;
468
469 fn parse_ref(
470 &self,
471 _cmd: &::clap::Command,
472 _arg: Option<&::clap::Arg>,
473 value: &std::ffi::OsStr,
474 ) -> Result<T, ::clap::Error> {
475 let s = value
476 .to_str()
477 .ok_or_else(|| ::clap::Error::new(::clap::error::ErrorKind::InvalidUtf8))?;
478 s.parse::<T>()
479 .map_err(|e| ::clap::Error::raw(::clap::error::ErrorKind::ValueValidation, e))
480 }
481
482 fn possible_values(&self) -> Option<Box<dyn Iterator<Item = clap::builder::PossibleValue> + '_>> {
483 self.variants.as_ref().map(|variants| {
484 let v: Vec<_> = variants
485 .iter()
486 .map(|s| clap::builder::PossibleValue::new(*s))
487 .collect();
488 Box::new(v.into_iter()) as Box<dyn Iterator<Item = clap::builder::PossibleValue>>
489 })
490 }
491}
492
493/// Runtime method metadata with string-based types.
494///
495/// This is a simplified, serialization-friendly representation of method
496/// information intended for runtime introspection and tooling. Types are
497/// stored as strings rather than `syn` AST nodes.
498///
499/// **Not to be confused with [`server_less_parse::MethodInfo`]**, which is
500/// the richer, `syn`-based representation used internally by proc macros
501/// during code generation. The parse version retains full type information
502/// (`syn::Type`, `syn::Ident`) and supports `#[param(...)]` attributes.
503#[derive(Debug, Clone)]
504pub struct MethodInfo {
505 /// Method name (e.g., "create_user")
506 pub name: String,
507 /// Documentation string from /// comments
508 pub docs: Option<String>,
509 /// Parameter names and their type strings
510 pub params: Vec<ParamInfo>,
511 /// Return type string
512 pub return_type: String,
513 /// Whether the method is async
514 pub is_async: bool,
515 /// Whether the return type is a Stream
516 pub is_streaming: bool,
517 /// Whether the return type is `Option<T>`
518 pub is_optional: bool,
519 /// Whether the return type is `Result<T, E>`
520 pub is_result: bool,
521 /// Group display name for categorization
522 pub group: Option<String>,
523}
524
525/// Runtime parameter metadata with string-based types.
526///
527/// See [`MethodInfo`] for the relationship between this type and
528/// `server_less_parse::ParamInfo`.
529#[derive(Debug, Clone)]
530pub struct ParamInfo {
531 /// Parameter name
532 pub name: String,
533 /// Type as string
534 pub ty: String,
535 /// Whether this is an `Option<T>`
536 pub is_optional: bool,
537 /// Whether this looks like an ID parameter (ends with _id or is named id)
538 pub is_id: bool,
539}
540
541/// Runtime HTTP method enum used in generated introspection code.
542///
543/// See also [`server_less_parse::HttpMethod`] for the compile-time equivalent used
544/// by proc macros during code generation.
545#[derive(Debug, Clone, Copy, PartialEq, Eq)]
546pub enum HttpMethod {
547 Get,
548 Post,
549 Put,
550 Patch,
551 Delete,
552}
553
554impl HttpMethod {
555 /// Infer HTTP method from function name prefix
556 pub fn infer_from_name(name: &str) -> Self {
557 if name.starts_with("get_")
558 || name.starts_with("fetch_")
559 || name.starts_with("read_")
560 || name.starts_with("list_")
561 || name.starts_with("find_")
562 || name.starts_with("search_")
563 {
564 HttpMethod::Get
565 } else if name.starts_with("create_")
566 || name.starts_with("add_")
567 || name.starts_with("new_")
568 {
569 HttpMethod::Post
570 } else if name.starts_with("update_") || name.starts_with("set_") {
571 HttpMethod::Put
572 } else if name.starts_with("patch_") || name.starts_with("modify_") {
573 HttpMethod::Patch
574 } else if name.starts_with("delete_") || name.starts_with("remove_") {
575 HttpMethod::Delete
576 } else {
577 // Default to POST for RPC-style methods
578 HttpMethod::Post
579 }
580 }
581
582 /// Returns the HTTP method as an uppercase string slice (e.g. `"GET"`, `"POST"`).
583 pub fn as_str(&self) -> &'static str {
584 match self {
585 HttpMethod::Get => "GET",
586 HttpMethod::Post => "POST",
587 HttpMethod::Put => "PUT",
588 HttpMethod::Patch => "PATCH",
589 HttpMethod::Delete => "DELETE",
590 }
591 }
592}
593
594/// Pluralize an English word using common rules.
595///
596/// Rules applied in order:
597/// - Already ends in `s`, `x`, `z`, `ch`, or `sh` → append `es`
598/// - Ends in a consonant followed by `y` → replace `y` with `ies`
599/// - Everything else → append `s`
600fn pluralize(word: &str) -> String {
601 if word.ends_with('s')
602 || word.ends_with('x')
603 || word.ends_with('z')
604 || word.ends_with("ch")
605 || word.ends_with("sh")
606 {
607 format!("{word}es")
608 } else if word.ends_with('y')
609 && word.len() >= 2
610 && !matches!(
611 word.as_bytes()[word.len() - 2],
612 b'a' | b'e' | b'i' | b'o' | b'u'
613 )
614 {
615 // consonant + y → drop y, add ies
616 format!("{}ies", &word[..word.len() - 1])
617 } else {
618 format!("{word}s")
619 }
620}
621
622/// Infer a REST path from a method name and HTTP method.
623///
624/// This is the runtime version used in generated introspection code. It does not
625/// have access to parameter information, so it cannot infer `/{id}` paths contextually.
626/// See `server_less_parse` (or `server_less_macros::openapi_gen`) for the richer
627/// compile-time version that uses parameter names to infer `/{id}` paths.
628pub fn infer_path(method_name: &str, http_method: HttpMethod) -> String {
629 // Strip common prefixes to get the resource name
630 let resource = method_name
631 .strip_prefix("get_")
632 .or_else(|| method_name.strip_prefix("fetch_"))
633 .or_else(|| method_name.strip_prefix("read_"))
634 .or_else(|| method_name.strip_prefix("list_"))
635 .or_else(|| method_name.strip_prefix("find_"))
636 .or_else(|| method_name.strip_prefix("search_"))
637 .or_else(|| method_name.strip_prefix("create_"))
638 .or_else(|| method_name.strip_prefix("add_"))
639 .or_else(|| method_name.strip_prefix("new_"))
640 .or_else(|| method_name.strip_prefix("update_"))
641 .or_else(|| method_name.strip_prefix("set_"))
642 .or_else(|| method_name.strip_prefix("patch_"))
643 .or_else(|| method_name.strip_prefix("modify_"))
644 .or_else(|| method_name.strip_prefix("delete_"))
645 .or_else(|| method_name.strip_prefix("remove_"))
646 .unwrap_or(method_name);
647
648 // Pluralize for collection endpoints.
649 // If the resource already ends in 's' it is likely already plural (e.g. the
650 // caller wrote `list_users` and we stripped the prefix to get "users").
651 // For singular forms we apply English pluralization rules.
652 let path_resource = if resource.ends_with('s') {
653 resource.to_string()
654 } else {
655 pluralize(resource)
656 };
657
658 match http_method {
659 // Collection operations
660 HttpMethod::Post => format!("/{path_resource}"),
661 HttpMethod::Get
662 if method_name.starts_with("list_")
663 || method_name.starts_with("search_")
664 || method_name.starts_with("find_") =>
665 {
666 format!("/{path_resource}")
667 }
668 // Single resource operations
669 HttpMethod::Get | HttpMethod::Put | HttpMethod::Patch | HttpMethod::Delete => {
670 format!("/{path_resource}/{{id}}")
671 }
672 }
673}
674
675#[cfg(test)]
676mod tests {
677 use super::*;
678
679 #[test]
680 fn test_pluralize() {
681 // ends in s/x/z/ch/sh → add es
682 assert_eq!(pluralize("index"), "indexes");
683 assert_eq!(pluralize("status"), "statuses");
684 assert_eq!(pluralize("match"), "matches");
685 assert_eq!(pluralize("box"), "boxes");
686 assert_eq!(pluralize("buzz"), "buzzes");
687 assert_eq!(pluralize("brush"), "brushes");
688 // consonant + y → ies
689 assert_eq!(pluralize("query"), "queries");
690 // vowel + y → plain s (key, day, …)
691 assert_eq!(pluralize("key"), "keys");
692 // default → add s
693 assert_eq!(pluralize("item"), "items");
694 }
695
696 #[test]
697 fn test_http_method_inference() {
698 assert_eq!(HttpMethod::infer_from_name("get_user"), HttpMethod::Get);
699 assert_eq!(HttpMethod::infer_from_name("list_users"), HttpMethod::Get);
700 assert_eq!(HttpMethod::infer_from_name("create_user"), HttpMethod::Post);
701 assert_eq!(HttpMethod::infer_from_name("update_user"), HttpMethod::Put);
702 assert_eq!(
703 HttpMethod::infer_from_name("delete_user"),
704 HttpMethod::Delete
705 );
706 assert_eq!(
707 HttpMethod::infer_from_name("do_something"),
708 HttpMethod::Post
709 ); // RPC fallback
710 }
711
712 #[test]
713 fn test_path_inference() {
714 assert_eq!(infer_path("create_user", HttpMethod::Post), "/users");
715 assert_eq!(infer_path("get_user", HttpMethod::Get), "/users/{id}");
716 assert_eq!(infer_path("list_users", HttpMethod::Get), "/users");
717 assert_eq!(infer_path("delete_user", HttpMethod::Delete), "/users/{id}");
718 }
719}