Skip to main content

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}