Skip to main content

olai_codegen/codegen/
mod.rs

1//! Code generation module for REST API handlers and language bindings.
2//!
3//! This module provides the core functionality for generating Rust code from
4//! protobuf metadata extracted from service definitions.
5//!
6//! ## Architecture
7//!
8//! The code generation process follows these phases:
9//! 1. **Analysis**: Process collected metadata to understand service structure
10//! 2. **Planning**: Determine what code needs to be generated
11//! 3. **Generation**: Create Rust code using templates and metadata
12//! 4. **Output**: Write generated code to appropriate files
13//!
14//! ## Generated Code Types
15//!
16//! - **Handler Traits**: Async trait definitions for service operations
17//! - **Request Extractors**: Axum FromRequest/FromRequestParts implementations
18//! - **Route Handlers**: Axum handler functions that delegate to traits
19//! - **Client Code**: HTTP client implementations for services
20//! - **Type Mappings**: Conversions between protobuf and Rust types
21
22use std::collections::{BTreeMap, BTreeSet, HashMap};
23
24use convert_case::{Case, Casing};
25use itertools::Itertools;
26use proc_macro2::TokenStream;
27use quote::{format_ident, quote};
28use syn::Ident;
29
30use crate::error::{Error, Result};
31
32use crate::analysis::{
33    BodyField, GenerationPlan, ManagedResource, MethodPlan, RequestParam, RequestType, ServicePlan,
34    analyze_metadata, split_body_fields,
35};
36use crate::output;
37use crate::parsing::types::{self, RenderContext, UnifiedType};
38use crate::parsing::{CodeGenMetadata, MessageField, MessageInfo};
39use crate::utils::last_segment;
40use crate::utils::strings::operation_to_method_name;
41
42mod aggregate;
43mod bindings;
44mod builder;
45mod client;
46mod client_handler;
47mod config;
48mod connect_client;
49mod error_module;
50mod handler;
51mod json_int64;
52pub(crate) mod node;
53mod python;
54mod resource_client;
55mod resources;
56mod server;
57mod tokens;
58mod transport;
59mod wasm;
60
61pub use config::{
62    BindingsConfig, ClientHandlerImpls, ClientProtocol, ClientProtocols, CodeGenConfig,
63    CodeGenOutput, DEFAULT_TRANSPORT_TYPE_PATH, ErrorModuleConfig, ModelsPath, PyPackageConfig,
64    PythonOptions, RestClientOptions, Runtime,
65};
66pub(crate) use tokens::{doc_tokens, format_tokens, format_tokens_static};
67pub use transport::REQWEST_TRANSPORT_TYPE_PATH;
68
69/// How a language binding lowers a method's call into the underlying Rust client.
70///
71/// Resource services expose a scoped client (e.g. `CatalogClient`) that captures path params;
72/// resource-less services have no such client, so their methods live on the root client and must
73/// pass every param themselves (the "flat" shape, treated like collection methods).
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub(crate) enum BindingMode {
76    /// Method lives on a resource-scoped client that already holds the path params.
77    Scoped,
78    /// Method lives on the root client and receives all params, including path params.
79    Flat,
80}
81
82/// Language-agnostic description of a service's resource accessor method on the aggregate client.
83///
84/// Computed once from the IR (`ServicePlan::resource_accessor_params`) and rendered by each language
85/// emitter, so the param list and nested-vs-flat decision are shared by construction.
86pub(crate) struct AccessorSpec {
87    /// The resource singular, used as the accessor method name (e.g. `catalog`).
88    pub(crate) singular: String,
89    /// The ordered accessor params (ancestors + `<singular>_name` leaf), e.g.
90    /// `["catalog_name", "schema_name"]` or `["name"]`.
91    pub(crate) params: Vec<String>,
92    /// Whether the resource is nested (has parent components); its full name is the dot-joined
93    /// params and the accessor delegates through a `<singular>_from_full_name` helper.
94    pub(crate) nested: bool,
95}
96
97impl AccessorSpec {
98    /// The `format!` template for joining the params into a composite full name
99    /// (e.g. `"{}.{}"` for two params). Only meaningful when [`Self::nested`] is true.
100    pub(crate) fn join_format(&self) -> String {
101        dot_format(self.params.len())
102    }
103}
104
105/// The `format!` template joining `n` values with dots (`"{}.{}"` for two).
106pub(crate) fn dot_format(n: usize) -> String {
107    std::iter::repeat_n("{}", n).collect::<Vec<_>>().join(".")
108}
109
110impl MethodPlan {
111    pub(crate) fn resource_client_method(&self) -> Ident {
112        match &self.scoped_verb {
113            Some(verb) => format_ident!("{}", verb),
114            None => format_ident!("{}", self.handler_function_name),
115        }
116    }
117
118    pub(crate) fn base_method_ident(&self) -> Ident {
119        format_ident!("{}", self.handler_function_name)
120    }
121}
122
123/// Generate all code described by `config` from `metadata`.
124///
125/// Writes the following outputs, depending on which [`CodeGenOutput`] fields are `Some`:
126///
127/// | Field | Contents |
128/// |-------|----------|
129/// | `output.common` | Axum request extractors (`extractors/<service>/`) and their `mod.rs` |
130/// | `output.models` | `labels.rs` (with `generate_resource_enum`), `pyo3_impls.rs` (with `output.python`) and the `mod.rs` mounting the models, in the `models_subdir` |
131/// | `output.server` | Handler trait + Axum route wiring per service |
132/// | `output.client` | HTTP (and/or ConnectRPC) client structs and request builders per service, plus the aggregate client |
133/// | `output.python` | PyO3 binding wrappers (and the Python package files) |
134/// | `output.node` | NAPI binding wrappers |
135/// | `output.node_ts` | TypeScript client (`client.ts`, `models/index.ts`) |
136/// | `output.wasm` | `#[wasm_bindgen]` bindings (`mod.rs`) and `client.d.ts` |
137///
138/// # Required fields
139///
140/// - `output.server` needs `output.common` (the extractors).
141/// - `bindings` must be `Some` when any of `output.python`, `output.node`, or `output.node_ts`
142///   is `Some`; otherwise returns [`Error::MissingBindingsConfig`].
143/// - `models_path_template` and `models_path_crate_template` must be valid Rust path templates
144///   (containing `{service}`); invalid templates return [`Error::InvalidModelsPathTemplate`].
145///
146/// # Optional fields
147///
148/// Setting `generate_resource_enum` to `false` skips `labels.rs` generation.
149/// Setting `bindings` to `None` skips all language-binding output.
150///
151/// Call [`CodeGenConfig::validate`] before this function to surface config errors at
152/// construction time rather than mid-generation.
153///
154/// # Errors
155///
156/// Returns:
157///
158/// - [`Error::InvalidModelsPathTemplate`] if `models_path_template` or `models_path_crate_template`
159///   is not a valid Rust path template.
160/// - [`Error::InvalidConfigPath`] if `context_type_path` or `result_type_path` is not a valid Rust
161///   path.
162/// - [`Error::MissingBindingsConfig`] if any of the `python`, `node`, or `node_ts` outputs is
163///   requested without a `bindings` config.
164/// - [`Error::Build`] if a REST client or Python option lacks an option it needs.
165/// - [`Error::MissingAnnotation`] or [`Error::Build`] surfaced while analyzing the metadata (e.g. a
166///   required `google.api` annotation is missing, or a service cannot be planned).
167/// - [`Error::InvalidRustPath`] if a proto message or field maps to an invalid Rust path, and
168///   [`Error::InvalidErrorTypePath`] if the configured `error_type_path` is not a valid Rust path.
169/// - [`Error::GeneratedParse`] if an internal generator produces tokens that do not parse as a Rust
170///   file.
171/// - [`Error::Io`] if creating the models directory or writing any generated file fails.
172pub fn generate_code(metadata: &CodeGenMetadata, config: &CodeGenConfig) -> Result<()> {
173    // Validate templates early so callers get a clean error before any generation starts.
174    let models_path = ModelsPath::new(&config.models_path_template)?;
175    let models_path_crate = ModelsPath::new(&config.models_path_crate_template)?;
176
177    // The context/result type paths come from user config and are parsed as `syn::Path` deep in
178    // the per-method generators (handler/client/server/builder). Validate them once here so a
179    // typo surfaces as a clean error instead of panicking mid-generation.
180    for (field, path) in [
181        ("context_type", &config.context_type_path),
182        ("result_type", &config.result_type_path),
183    ] {
184        syn::parse_str::<syn::Path>(path).map_err(|source| Error::InvalidConfigPath {
185            field,
186            path: path.clone(),
187            source,
188        })?;
189    }
190
191    // Validate that bindings config is present when language-binding output is requested.
192    if (config.output.python.is_some()
193        || config.output.node.is_some()
194        || config.output.node_ts.is_some())
195        && config.bindings.is_none()
196    {
197        return Err(Error::MissingBindingsConfig);
198    }
199
200    // The Connect client emitter needs the connect-rust client's import path.
201    if config.client_protocols.connect
202        && config.output.client.is_some()
203        && config.connect_client_path.is_none()
204    {
205        return Err(Error::MissingConnectClientPath);
206    }
207
208    config.validate_options()?;
209
210    let plan = analyze_metadata(metadata)?;
211
212    // Validate every service's `{service}` substitution up front so a base path that isn't a
213    // valid Rust path segment fails cleanly here rather than panicking inside per-service codegen.
214    for service in &plan.services {
215        models_path.validate_for(&service.base_path)?;
216        models_path_crate.validate_for(&service.base_path)?;
217    }
218
219    if let Some(ref common_dir) = config.output.common {
220        let common_code = generate_common_code(&plan, metadata, config)?;
221        output::write_generated_code(&common_code, common_dir)?;
222    }
223
224    if let Some(subdir) = config.output.models_subdir_path() {
225        write_models(&plan, metadata, config, &subdir)?;
226    }
227
228    if let Some(ref server_dir) = config.output.server {
229        let server_code = generate_server_code(&plan, metadata, config)?;
230        output::write_generated_code(&server_code, server_dir)?;
231    }
232
233    if let Some(ref client_dir) = config.output.client {
234        let client_code = generate_client_code(&plan, metadata, config)?;
235        output::write_generated_code(&client_code, client_dir)?;
236    }
237
238    if let Some(ref python_dir) = config.output.python {
239        let python_code = generate_python_code(&plan, metadata, config)?;
240        output::write_generated_code(&python_code, python_dir)?;
241
242        let package_files = python::package_files(config, &plan, metadata);
243        if !package_files.is_empty() {
244            let files = GeneratedCode {
245                files: package_files.into_iter().collect(),
246            };
247            output::write_generated_code(&files, &config.python_options().package.dir)?;
248        }
249    }
250
251    if let Some(ref node_dir) = config.output.node {
252        let node_code = generate_node_code(&plan, metadata, config)?;
253        output::write_generated_code(&node_code, node_dir)?;
254    }
255
256    if let Some(ref node_ts_dir) = config.output.node_ts {
257        let node_ts_code = generate_node_ts_code(&plan, metadata, config)?;
258        output::write_generated_code(&node_ts_code, node_ts_dir)?;
259    }
260
261    if let Some(ref wasm_dir) = config.output.wasm {
262        let wasm_code = generate_wasm_code(&plan, metadata, config)?;
263        output::write_generated_code(&wasm_code, wasm_dir)?;
264    }
265
266    Ok(())
267}
268
269/// Write the models `_gen` subdir: `labels.rs`, `pyo3_impls.rs` and the `mod.rs` that mounts them
270/// alongside the per-service model and extractor modules.
271fn write_models(
272    plan: &GenerationPlan,
273    metadata: &CodeGenMetadata,
274    config: &CodeGenConfig,
275    subdir: &std::path::Path,
276) -> Result<()> {
277    std::fs::create_dir_all(subdir)?;
278
279    if config.generate_resource_enum {
280        let resource_enum = resources::generate_resource_enum(
281            plan,
282            metadata,
283            config,
284            config.error_type_path.as_deref(),
285        )?;
286        output::write_generated_code(&GeneratedCode::single("labels.rs", resource_enum), subdir)?;
287    }
288
289    // When Python bindings are generated, the bindings pass model types
290    // across the FFI boundary by their bare type, so the model crate must
291    // carry `FromPyObject`/`IntoPyObject` impls. Emit them here (orphan rule:
292    // the model types are local to this crate). Gated on the `python` feature
293    // in the generated `mod.rs`.
294    let mut include_pyo3_impls = false;
295    if config.output.python.is_some() {
296        let pyo3_impls = python::generate_pyo3_impls(plan, metadata, config)?;
297        if !pyo3_impls.is_empty() {
298            output::write_generated_code(
299                &GeneratedCode::single("pyo3_impls.rs", pyo3_impls),
300                subdir,
301            )?;
302            include_pyo3_impls = true;
303        }
304    }
305
306    // The Axum extractors are co-located in this same directory (the common
307    // generator wrote `<svc>/server.rs` + `<svc>/mod.rs` here), and this
308    // `mod.rs` overwrites the common generator's top-level `mod.rs` — so it
309    // re-declares the per-service extractor submodules it emitted.
310    let mut mod_content = generate_models_mod(
311        &plan.services,
312        config.generate_resource_enum,
313        include_pyo3_impls,
314        config.output.common.is_some(),
315        metadata,
316        config.runtime,
317    );
318    if config.rest_client.json_int64_as_number {
319        mod_content.push_str(&json_int64::module());
320        if metadata_has_json_fields(metadata) {
321            mod_content.push_str(&json_int64::value_module());
322        }
323    }
324    // Route through write_generated_code so the `// @generated` header is prepended like
325    // every other emitted file, rather than writing it raw.
326    output::write_generated_code(&GeneratedCode::single("mod.rs", mod_content), subdir)
327}
328
329/// Whether a project message has a free-form JSON field (`google.protobuf.Value`, `Struct`,
330/// `ListValue`), also as a map value or oneof variant.
331pub(crate) fn metadata_has_json_fields(metadata: &CodeGenMetadata) -> bool {
332    metadata
333        .messages
334        .iter()
335        .filter(|(name, _)| !name.starts_with(".google."))
336        .flat_map(|(_, info)| &info.fields)
337        .any(|field| {
338            types::JsonType::of_field(&field.unified_type).is_some()
339                || field
340                    .oneof_variants
341                    .iter()
342                    .flatten()
343                    .any(|v| types::JsonType::of_field(&v.field_type).is_some())
344        })
345}
346
347fn generate_common_code(
348    plan: &GenerationPlan,
349    metadata: &CodeGenMetadata,
350    config: &CodeGenConfig,
351) -> Result<GeneratedCode> {
352    let mut files = HashMap::new();
353
354    for handler in ServiceHandler::for_plan(plan, metadata, config) {
355        let service = handler.plan;
356        let server_code = server::generate_common(&handler)?;
357        // Nest the extractors under an `extractors/` subdir so their per-service
358        // module names can't collide with the co-located model package modules
359        // (e.g. a `example.agent_skills.v0alpha1` package yields a model
360        // module `agent_skills` that would otherwise clash with the `agent_skills`
361        // service extractor module).
362        files.insert(
363            format!("{EXTRACTORS_SUBDIR}/{}/server.rs", service.base_path),
364            server_code,
365        );
366        let module_code = generate_common_module();
367        files.insert(
368            format!("{EXTRACTORS_SUBDIR}/{}/mod.rs", service.base_path),
369            module_code,
370        );
371    }
372
373    // The `extractors/mod.rs` that declares the per-service extractor submodules.
374    files.insert(
375        format!("{EXTRACTORS_SUBDIR}/mod.rs"),
376        generate_extractors_module(&plan.services),
377    );
378
379    let module_code = main_module(&plan.services);
380    files.insert("mod.rs".to_string(), module_code);
381
382    Ok(GeneratedCode { files })
383}
384
385fn generate_server_code(
386    plan: &GenerationPlan,
387    metadata: &CodeGenMetadata,
388    config: &CodeGenConfig,
389) -> Result<GeneratedCode> {
390    let mut files = HashMap::new();
391
392    for handler in ServiceHandler::for_plan(plan, metadata, config) {
393        let service = handler.plan;
394        let trait_code = handler::generate(&handler)?;
395        files.insert(format!("{}/handler.rs", service.base_path), trait_code);
396        let server_code = server::generate_server(&handler)?;
397        files.insert(format!("{}/server.rs", service.base_path), server_code);
398        let module_code = generate_server_module(service);
399        files.insert(format!("{}/mod.rs", service.base_path), module_code);
400    }
401
402    let module_code = main_module(&plan.services);
403    files.insert("mod.rs".to_string(), module_code);
404
405    Ok(GeneratedCode { files })
406}
407
408fn generate_python_code(
409    plan: &GenerationPlan,
410    metadata: &CodeGenMetadata,
411    config: &CodeGenConfig,
412) -> Result<GeneratedCode> {
413    let mut files = HashMap::new();
414
415    let handlers = ServiceHandler::for_plan(plan, metadata, config);
416
417    // Per-service scoped client modules are only meaningful for services with a scoped binding.
418    // The methods of resource-less and flat-lowered services live on the root client (emitted by
419    // `main_module`), so they don't get a scoped module of their own.
420    for service in handlers.iter().filter(|s| s.emits_scoped_binding()) {
421        let python_code = python::generate(service)?;
422        files.insert(format!("{}.rs", service.plan.base_path), python_code);
423    }
424
425    let module_code = python::main_module(&handlers)?;
426    files.insert("mod.rs".to_string(), module_code);
427    files.extend(python::helper_files(config));
428
429    Ok(GeneratedCode { files })
430}
431
432fn generate_node_code(
433    plan: &GenerationPlan,
434    metadata: &CodeGenMetadata,
435    config: &CodeGenConfig,
436) -> Result<GeneratedCode> {
437    let mut files = HashMap::new();
438
439    let handlers = ServiceHandler::for_plan(plan, metadata, config);
440
441    // See `generate_python_code`: only services with a scoped binding get a per-service scoped
442    // module; resource-less and flat-lowered services' methods live on the root client.
443    for service in handlers.iter().filter(|s| s.emits_scoped_binding()) {
444        let napi_code = node::generate(service)?;
445        files.insert(format!("{}.rs", service.plan.base_path), napi_code);
446    }
447
448    let module_code = node::main_module(&handlers)?;
449    files.insert("mod.rs".to_string(), module_code);
450
451    Ok(GeneratedCode { files })
452}
453
454fn generate_node_ts_code(
455    plan: &GenerationPlan,
456    metadata: &CodeGenMetadata,
457    config: &CodeGenConfig,
458) -> Result<GeneratedCode> {
459    let handlers = ServiceHandler::for_plan(plan, metadata, config);
460
461    let ts_code = node::typescript::generate_client_ts(&handlers);
462    let mut files = HashMap::new();
463    files.insert("client.ts".to_string(), ts_code);
464
465    // The `models/index.ts` barrel re-exports every service's generated protobuf-es modules so
466    // `client.ts`'s `from "./models"` imports (message and service request/response types) resolve.
467    let models_barrel = node::typescript::generate_models_barrel(&handlers);
468    files.insert("models/index.ts".to_string(), models_barrel);
469
470    Ok(GeneratedCode { files })
471}
472
473/// Generate the WASM/browser `#[wasm_bindgen]` binding layer plus its `.d.ts`.
474///
475/// Emits `bindings.rs` (a `#[wasm_bindgen]` wrapper per service client, exchanging request/response
476/// values as plain JS objects via `serde-wasm-bindgen`) and `client.d.ts` (TypeScript declarations
477/// for JS/TS consumers). The wrapped clients are the generated low-level clients, which must be
478/// generated with the WASM transport (`transport_type_path = "olai_http_wasm::WasmClient"`).
479fn generate_wasm_code(
480    plan: &GenerationPlan,
481    metadata: &CodeGenMetadata,
482    config: &CodeGenConfig,
483) -> Result<GeneratedCode> {
484    let handlers = ServiceHandler::for_plan(plan, metadata, config);
485
486    let mut files = HashMap::new();
487    // Emit as `mod.rs` so the client crate mounts the bindings with a plain
488    // `mod wasm;` (no `#[path]` alias) over the `wasm/` convention subdir.
489    files.insert("mod.rs".to_string(), wasm::generate_bindings(&handlers)?);
490    files.insert("client.d.ts".to_string(), wasm::generate_dts(&handlers));
491
492    Ok(GeneratedCode { files })
493}
494
495fn generate_client_code(
496    plan: &GenerationPlan,
497    metadata: &CodeGenMetadata,
498    config: &CodeGenConfig,
499) -> Result<GeneratedCode> {
500    let mut files = HashMap::new();
501    let protocols = config.client_protocols.selected();
502    let multi = config.client_protocols.is_multi();
503    // Module path and type of each resource-scoped client, for the root re-exports.
504    let mut resource_clients = Vec::new();
505
506    for handler in ServiceHandler::for_plan(plan, metadata, config) {
507        let service = handler.plan;
508
509        // Emit each selected protocol's low-level client + builders. With a single protocol the
510        // files are flat under `<service>/`; with both they go under `<service>/<protocol>/` to
511        // avoid the two `client.rs`/`builders.rs` colliding. The shared builder layer binds to the
512        // protocol's low-level client and only calls `client.<method>(&request)`.
513        for &protocol in &protocols {
514            let prefix = if multi {
515                format!("{}/{}", service.base_path, protocol.subdir())
516            } else {
517                service.base_path.clone()
518            };
519
520            let client_code = match protocol {
521                ClientProtocol::Rest => client::generate(&handler)?,
522                ClientProtocol::Connect => connect_client::generate(&handler)?,
523            };
524            files.insert(format!("{prefix}/client.rs"), client_code);
525            files.insert(
526                format!("{prefix}/builders.rs"),
527                builder::generate(&handler, protocol)?,
528            );
529
530            // Ergonomic resource-scoped clients are a REST-only feature (driven by
531            // `google.api.resource` hierarchies). ConnectRPC-only protos rarely carry those, so the
532            // Connect client emits just the flat per-service client + builders.
533            let resource_code =
534                if protocol == ClientProtocol::Rest && config.output.generate_resource_clients {
535                    resource_client::generate(&handler, plan)?
536                } else {
537                    None
538                };
539            let has_resource_client = resource_code.is_some();
540            if let Some(code) = resource_code {
541                files.insert(format!("{prefix}/resource.rs"), code);
542            }
543
544            if has_resource_client && let Some(scoped) = handler.scoped_client_type() {
545                resource_clients.push((prefix.clone(), scoped, handler.client_feature_cfg()));
546            }
547
548            // Handler-trait impls forward to the REST client, so only it gets them.
549            let handler_impls = config.rest_client.handler_impls.as_ref().filter(|impls| {
550                protocol == ClientProtocol::Rest && !impls.skip.contains(&service.base_path)
551            });
552            let handler_impl = match handler_impls {
553                Some(impls) => {
554                    let code = client_handler::generate(&handler, impls)?;
555                    files.insert(format!("{prefix}/handler.rs"), code);
556                    Some(impls.feature.as_str())
557                }
558                None => None,
559            };
560
561            files.insert(
562                format!("{prefix}/mod.rs"),
563                generate_client_module(has_resource_client, handler_impl),
564            );
565        }
566
567        // When both protocols are emitted under subdirs, the `<service>/mod.rs` mounts and re-exports
568        // them. (Single-protocol output already wrote a flat `<service>/mod.rs` above.)
569        if multi {
570            files.insert(
571                format!("{}/mod.rs", service.base_path),
572                generate_protocol_split_module(&protocols),
573            );
574        }
575    }
576
577    // Top-level aggregate root client (e.g. `ExampleClient`), emitted only when a bindings
578    // config (which carries the aggregate name) is present.
579    let aggregate_code = aggregate::generate(plan, metadata, config)?;
580    let has_aggregate = aggregate_code.is_some();
581    if let Some(code) = aggregate_code {
582        files.insert("client.rs".to_string(), code);
583    }
584
585    if config.uses_reqwest_transport() {
586        files.insert(
587            "transport.rs".to_string(),
588            transport::generate(config.rest_client.user_agent.as_deref()),
589        );
590    }
591    if let Some(errors) = &config.rest_client.error_module {
592        files.insert(
593            "error.rs".to_string(),
594            error_module::generate(config, errors, metadata)?,
595        );
596    }
597
598    files.insert(
599        "mod.rs".to_string(),
600        generate_client_main_module(plan, metadata, config, has_aggregate, &resource_clients),
601    );
602
603    Ok(GeneratedCode { files })
604}
605
606fn generate_common_module() -> String {
607    let tokens = quote! {
608        #[cfg(feature = "axum")]
609        pub mod server;
610    };
611    format_tokens_static(tokens)
612}
613
614/// Subdirectory (under the co-located models `_gen/` dir) that holds the Axum
615/// request extractors, one `<service>/server.rs` per service. Nesting them keeps
616/// their module names from colliding with the model package modules that share
617/// the dir.
618pub(crate) const EXTRACTORS_SUBDIR: &str = "extractors";
619
620/// The `extractors/mod.rs` that declares one `pub mod <service>;` per service.
621/// Axum-coupled, so gated on the `axum` feature.
622fn generate_extractors_module(services: &[ServicePlan]) -> String {
623    let decls: Vec<TokenStream> = services
624        .iter()
625        .map(|s| format_ident!("{}", s.base_path))
626        // Dedupe: multiple services can share a base_path.
627        .collect::<BTreeSet<_>>()
628        .into_iter()
629        .map(|m| quote! { #[cfg(feature = "axum")] pub mod #m; })
630        .collect();
631    format_tokens_static(quote! { #(#decls)* })
632}
633
634fn generate_server_module(service: &ServicePlan) -> String {
635    let handler_ident = format_ident!("{}", service.handler_name);
636    let tokens = quote! {
637        pub use handler::#handler_ident;
638
639        mod handler;
640        #[cfg(feature = "axum")]
641        pub mod server;
642    };
643    format_tokens_static(tokens)
644}
645
646/// `handler_impl` is the cargo feature gating the service's handler-trait impl module, when one
647/// was generated for it.
648///
649/// Emit the scoped resource client module only when it was generated for this service.
650fn generate_client_module(has_resource_client: bool, handler_impl: Option<&str>) -> String {
651    let allow = client_module_allow();
652    // Glob re-exports of the per-service submodules. Not every submodule
653    // contributes a name that the parent's consumers use directly, so a
654    // `pub use <mod>::*` can trip `unused_imports` under `-D warnings` (notably
655    // under buffa, where some helper types are unreferenced); allow it.
656    let reexport_allow = quote! { #[allow(unused_imports)] };
657    let resource_module = if has_resource_client {
658        quote! {
659            #reexport_allow
660            pub use resource::*;
661            #allow
662            pub mod resource;
663        }
664    } else {
665        quote! {}
666    };
667    // The impls are trait impls only (nothing to re-export). Client futures aren't `Send` on
668    // wasm32, which `async_trait` handler traits require, so the module is native-only.
669    let handler_module = match handler_impl {
670        Some(feature) => quote! {
671            #[cfg(all(feature = #feature, not(target_arch = "wasm32")))]
672            mod handler;
673        },
674        None => quote! {},
675    };
676    let tokens = quote! {
677        #reexport_allow
678        pub use client::*;
679        #reexport_allow
680        pub use builders::*;
681        #resource_module
682        #handler_module
683
684        #allow
685        pub mod client;
686        #allow
687        pub mod builders;
688    };
689    format_tokens_static(tokens)
690}
691
692/// The lints generated client modules structurally trip: the full client surface is emitted whether
693/// or not a given consumer calls every builder (`dead_code`), flat methods can exceed the
694/// arg-count threshold (`too_many_arguments`), and proto comment prose may wrap list items
695/// (`doc_lazy_continuation`). Allowed on the module so consumers' own `-D warnings` builds stay
696/// clean; genuine issues are fixed in the generator.
697fn client_module_allow() -> TokenStream {
698    quote! {
699        #[allow(dead_code, clippy::too_many_arguments, clippy::doc_lazy_continuation)]
700    }
701}
702
703/// The `<service>/mod.rs` for a service that emits **both** protocols: mount each protocol's client
704/// as its own submodule (`pub mod rest;` / `pub mod connect;`).
705///
706/// They are *not* glob-re-exported into the service module: both protocols define identically-named
707/// types (`GetThingBuilder`, …), so a flat re-export would collide. Callers disambiguate by module
708/// (`read::rest::GetThingBuilder` vs `read::connect::GetThingBuilder`).
709fn generate_protocol_split_module(protocols: &[ClientProtocol]) -> String {
710    let allow = client_module_allow();
711    let mods: Vec<TokenStream> = protocols
712        .iter()
713        .map(|p| {
714            let m = format_ident!("{}", p.subdir());
715            quote! { #allow pub mod #m; }
716        })
717        .collect();
718    format_tokens_static(quote! { #(#mods)* })
719}
720
721pub fn main_module(services: &[ServicePlan]) -> String {
722    let service_modules: Vec<TokenStream> = services
723        .iter()
724        .map(|s| {
725            let module_name = format_ident!("{}", s.base_path);
726            quote! { pub mod #module_name; }
727        })
728        .collect();
729
730    let tokens = quote! {
731        #(#service_modules)*
732    };
733    format_tokens_static(tokens)
734}
735
736/// The client root `mod.rs`. It re-exports the `resource_clients` (module path, type and the
737/// service's feature gate) so a crate can export the whole client with `pub use codegen::*;`.
738fn generate_client_main_module(
739    plan: &GenerationPlan,
740    metadata: &CodeGenMetadata,
741    config: &CodeGenConfig,
742    has_aggregate: bool,
743    resource_clients: &[(String, Ident, TokenStream)],
744) -> String {
745    let services = &plan.services;
746    let result_path = config.client_result_path();
747    let service_modules: Vec<TokenStream> = services
748        .iter()
749        .map(|s| {
750            let module_name = format_ident!("{}", s.base_path);
751            let cfg = ServiceHandler::new(s, metadata, config).client_feature_cfg();
752            quote! { #cfg pub mod #module_name; }
753        })
754        .collect();
755    let resource_exports: Vec<TokenStream> = resource_clients
756        .iter()
757        .map(|(path, scoped, cfg)| {
758            let segments = path.split('/').map(|segment| format_ident!("{}", segment));
759            quote! { #cfg pub use #(#segments::)*#scoped; }
760        })
761        .collect();
762
763    // Export the aggregate root client (`client.rs`) when it was generated. Per-service files live
764    // under `<base_path>/`, so a top-level `client` module never collides with them.
765    // The aggregate client has many flat methods (arg-count) and renders proto
766    // comment prose (doc list wrapping), so allow those structural lints.
767    let aggregate_module = if has_aggregate {
768        quote! {
769            #[allow(clippy::too_many_arguments, clippy::doc_lazy_continuation)]
770            pub mod client;
771            #[allow(unused_imports)]
772            pub use client::*;
773        }
774    } else {
775        quote! {}
776    };
777
778    // The `stream_paginated` helper (and its `futures::Future` import) is used
779    // only by `List` builders' `into_stream`. Emit it solely when some service
780    // has a List method, so it isn't dead code otherwise.
781    let has_list = services
782        .iter()
783        .flat_map(|s| s.methods.iter())
784        .any(|m| matches!(m.request_type, RequestType::List));
785    // With per-resource features, every List-bearing service may be disabled.
786    let pagination_allow = if config.rest_client.resource_features {
787        quote! { #[allow(dead_code)] }
788    } else {
789        quote! {}
790    };
791    let pagination = if has_list {
792        quote! {
793            use futures::Future;
794
795            #pagination_allow
796            pub(super) fn stream_paginated<F, Fut, S, T>(
797                state: S,
798                op: F,
799            ) -> impl futures::Stream<Item = #result_path<T>>
800            where
801                F: Fn(S, Option<String>) -> Fut + Copy,
802                Fut: Future<Output = #result_path<(T, S, Option<String>)>>,
803            {
804                enum PaginationState<T> {
805                    Start(T),
806                    HasMore(T, String),
807                    Done,
808                }
809
810                futures::stream::unfold(
811                    PaginationState::Start(state),
812                    move |state| async move {
813                        let (s, page_token) = match state {
814                            PaginationState::Start(s) => (s, None),
815                            PaginationState::HasMore(s, page_token) if !page_token.is_empty() => {
816                                (s, Some(page_token))
817                            }
818                            _ => {
819                                return None;
820                            }
821                        };
822
823                        let (resp, s, continuation) = match op(s, page_token).await {
824                            Ok(resp) => resp,
825                            Err(e) => return Some((Err(e), PaginationState::Done)),
826                        };
827
828                        let next_state = match continuation {
829                            Some(token) => PaginationState::HasMore(s, token),
830                            None => PaginationState::Done,
831                        };
832
833                        Some((Ok(resp), next_state))
834                    },
835                )
836            }
837        }
838    } else {
839        quote! {}
840    };
841
842    let transport_module = config
843        .uses_reqwest_transport()
844        .then(|| quote! { pub mod transport; });
845    let error_module = config
846        .rest_client
847        .error_module
848        .is_some()
849        .then(|| quote! { pub mod error; });
850    let encode_path_value = services
851        .iter()
852        .flat_map(|s| &s.methods)
853        .any(MethodPlan::formats_path_values)
854        .then(|| {
855            let helper = client::encode_path_value_fn();
856            // With per-resource features, every service that formats path values may be off.
857            quote! { #pagination_allow #helper }
858        });
859
860    let tokens = quote! {
861        #(#service_modules)*
862
863        #(#resource_exports)*
864
865        #aggregate_module
866        #transport_module
867        #error_module
868
869        #pagination
870        #encode_path_value
871    };
872    format_tokens_static(tokens)
873}
874
875/// Generate the `mod.rs` for `crates/common/src/models/`.
876///
877/// Emits `pub mod <service> { pub mod <version> { include!("./<pkg>.rs") } }` blocks
878/// for every service in the plan, plus static re-exports and module declarations.
879/// The model files are co-located in this directory (written by the buf plugin), so
880/// the includes are plain sibling references.
881///
882/// When `include_labels` is `true`, also emits `pub mod labels; pub use labels::{ObjectLabel, Resource};`.
883/// When `include_extractors` is `true`, also declares `pub mod extractors;`.
884///
885/// `metadata` is used to discover all resource-annotated messages (even those not returned
886/// directly by an RPC) so they can be included in `pub use` re-exports.
887pub fn generate_models_mod(
888    services: &[ServicePlan],
889    include_labels: bool,
890    include_pyo3_impls: bool,
891    include_extractors: bool,
892    metadata: &CodeGenMetadata,
893    runtime: Runtime,
894) -> String {
895    // Services are keyed by proto package, not by service: multiple services can share a
896    // package (e.g. `CatalogService` and `SchemaService` both in `example.catalog.v1`), and
897    // emitting one `pub mod`/`pub use` block per service would produce duplicate modules and
898    // imports (E0428). Group by package first, then emit once per package.
899    //
900    // package = "example.catalogs.v1"
901    // parts   = ["example", "catalogs", "v1"]
902    // service module = second-to-last segment ("catalogs"); version = last segment ("v1").
903    let mod_segments = |svc: &ServicePlan| -> (String, String) {
904        let parts: Vec<&str> = svc.package.split('.').collect();
905        if parts.len() >= 2 {
906            (
907                parts[parts.len() - 2].to_string(),
908                parts[parts.len() - 1].to_string(),
909            )
910        } else {
911            (svc.base_path.clone(), "v1".to_string())
912        }
913    };
914
915    // One entry per package, in sorted order, with the union of re-exported type names.
916    let mut packages: BTreeMap<String, (String, String, BTreeSet<String>)> = BTreeMap::new();
917    for svc in services {
918        let (svc_seg, ver_seg) = mod_segments(svc);
919        let entry = packages
920            .entry(svc.package.clone())
921            .or_insert_with(|| (svc_seg, ver_seg, BTreeSet::new()));
922        let type_names = &mut entry.2;
923
924        // Re-export managed resources for this service...
925        type_names.extend(svc.managed_resources.iter().map(|r| r.type_name.clone()));
926
927        // ...plus every resource-annotated message in the same package (catches nested types
928        // like `Column` that aren't direct RPC return types but still carry annotations).
929        let fq_prefix = format!(".{}.", svc.package);
930        let fq_pkg = format!(".{}", svc.package);
931        for (fq_name, msg_info) in &metadata.messages {
932            if msg_info.resource_descriptor.is_some()
933                && (fq_name.starts_with(&fq_prefix) || fq_name.starts_with(&fq_pkg))
934            {
935                type_names.insert(last_segment(fq_name).to_string());
936            }
937        }
938    }
939
940    let mut service_mods: Vec<TokenStream> = Vec::new();
941    let mut reexports: Vec<TokenStream> = Vec::new();
942    for (package, (svc_seg, ver_seg, type_names)) in &packages {
943        let svc_mod = format_ident!("{}", svc_seg);
944        let ver_mod = format_ident!("{}", ver_seg);
945
946        // Models are co-located: the buf plugin writes `<package>.rs` (and, for
947        // prost, `<package>.serde.rs` / `<package>.tonic.rs`) into this same
948        // directory, so the include is a plain sibling reference.
949        let main_include = format!("./{package}.rs");
950        // The separate `<package>.tonic.rs` (grpc service stubs over the prost
951        // `ProstCodec`) is only emitted by the prost model plugin. The buffa plugin
952        // produces no such file, so including it would break the build whenever the
953        // `grpc` feature is enabled (e.g. CI's `--all-features`). Gate the include on
954        // the runtime, not just the `grpc` cfg.
955        let tonic_include_tokens = match runtime {
956            Runtime::Prost => {
957                let tonic_include = format!("./{package}.tonic.rs");
958                quote! {
959                    #[cfg(feature = "grpc")]
960                    include!(#tonic_include);
961                }
962            }
963            Runtime::Buffa => quote! {},
964        };
965        service_mods.push(quote! {
966            pub mod #svc_mod {
967                pub mod #ver_mod {
968                    include!(#main_include);
969                    #tonic_include_tokens
970                }
971            }
972        });
973
974        for type_name in type_names {
975            let type_ident = format_ident!("{}", type_name);
976            reexports.push(quote! {
977                pub use #svc_mod::#ver_mod::#type_ident;
978            });
979        }
980    }
981
982    let labels_decl: TokenStream = if include_labels {
983        quote! {
984            pub mod labels;
985            pub use labels::{ObjectLabel, Resource};
986        }
987    } else {
988        quote! {}
989    };
990
991    // PyO3 boundary conversions for the model types (see `python::generate_pyo3_impls`).
992    // Gated on the `python` feature so non-Python consumers never pull in pyo3.
993    let pyo3_impls_decl: TokenStream = if include_pyo3_impls {
994        quote! {
995            // pyo3's `#[pyclass(from_py_object)]` extracts via `Clone::clone`, which trips
996            // `clippy::clone_on_copy` on the (`Copy`) enum wrappers. The call lives in the
997            // macro-generated impl, so only a module-level allow can reach it.
998            #[cfg(feature = "python")]
999            #[allow(clippy::clone_on_copy)]
1000            pub mod pyo3_impls;
1001            // Re-export the `Py*` wrapper `#[pyclass]` types at the models root so the
1002            // generated language bindings (and consumers) can name them without
1003            // reaching into the `pyo3_impls` submodule.
1004            #[cfg(feature = "python")]
1005            pub use pyo3_impls::*;
1006        }
1007    } else {
1008        quote! {}
1009    };
1010
1011    // The Axum extractors, when generated, are co-located in this directory under an
1012    // `extractors/` subdir (the common generator wrote `extractors/<svc>/...`
1013    // here), and this `mod.rs` overwrites the top-level `mod.rs` the common
1014    // generator emitted — so it must re-declare the `extractors` module.
1015    // Axum-coupled, so gate it on the `axum` feature. Without this, the
1016    // `FromRequest`/`FromRequestParts` impls never compile and the generated
1017    // route fns can't be mounted. Nesting under `extractors` also avoids the
1018    // per-service module names clashing with the model package modules above.
1019    let common_mods: TokenStream = if include_extractors {
1020        let extractors_mod = format_ident!("{EXTRACTORS_SUBDIR}");
1021        quote! {
1022            #[cfg(feature = "axum")]
1023            pub mod #extractors_mod;
1024        }
1025    } else {
1026        quote! {}
1027    };
1028
1029    // Lints to silence across this module and every `include!`d model file below.
1030    // These are inner attributes, so they cover the descendant `pub mod <svc>::<v1>`
1031    // modules the per-package files are pasted into.
1032    let lint_allows = match runtime {
1033        // The buffa model files carry no allows of their own, so silence their lints here,
1034        // as buffa's own include file does.
1035        Runtime::Buffa => quote! {
1036            #![allow(non_camel_case_types)]
1037            #![allow(clippy::derivable_impls)]
1038            #![allow(clippy::match_single_binding)]
1039            #![allow(clippy::uninlined_format_args)]
1040            #![allow(clippy::doc_lazy_continuation)]
1041        },
1042        Runtime::Prost => quote! {},
1043    };
1044
1045    let tokens = quote! {
1046        // The per-package modules below gate a `tonic.rs` include on
1047        // `#[cfg(feature = "grpc")]`. Consumers that don't define a `grpc`
1048        // feature would otherwise see `unexpected_cfgs`; allow it here.
1049        #![allow(unexpected_cfgs)]
1050        // The grpc-gated `tonic.rs` includes carry empty (`///`) doc comments on
1051        // generated service methods; allow them so `clippy -D warnings` stays
1052        // green when the `grpc` feature is enabled.
1053        #![allow(clippy::empty_docs)]
1054        // The prost-serde plugin emits `write!(f, "...", &FIELDS)` for the
1055        // deserializer's expected-fields message; newer clippy flags the `&` as a
1056        // useless borrow in a formatting macro. Allow it here so plugin output
1057        // stays `-D warnings` clean without post-processing each generated serde file.
1058        #![allow(clippy::useless_borrows_in_formatting)]
1059        #lint_allows
1060
1061        use std::collections::HashMap;
1062
1063        #labels_decl
1064
1065        #pyo3_impls_decl
1066
1067        #(#reexports)*
1068
1069        pub type PropertyMap = HashMap<String, serde_json::Value>;
1070
1071        #(#service_mods)*
1072
1073        #common_mods
1074    };
1075
1076    format_tokens_static(tokens)
1077}
1078
1079/// An in-memory bundle of generated source files awaiting output.
1080///
1081/// Each generator phase (common, server, client, bindings, …) returns one of these; the
1082/// [`crate::output`] writer then materializes the [`files`](Self::files) onto disk, prepending the
1083/// `// @generated` header to each. Keeping generation and writing separate lets generation stay
1084/// pure (no I/O) and makes the produced files easy to assert on in tests.
1085#[derive(Debug)]
1086pub struct GeneratedCode {
1087    /// Generated files keyed by path relative to the phase's output directory.
1088    pub files: HashMap<String, String>,
1089}
1090
1091impl GeneratedCode {
1092    /// A single generated file.
1093    fn single(path: &str, content: String) -> Self {
1094        Self {
1095            files: HashMap::from([(path.to_string(), content)]),
1096        }
1097    }
1098}
1099
1100/// The per-service generation context shared by every code generator.
1101///
1102/// Bundles the three borrows each generator phase needs for one service: its analyzed
1103/// [`ServicePlan`](crate::analysis::ServicePlan), the full [`CodeGenMetadata`] (for resolving
1104/// types referenced from other services), and the active [`CodeGenConfig`]. The various
1105/// `codegen::*` submodules take a `&ServiceHandler` and read these plus the derived accessors on
1106/// this type (client type names, models paths, [`Self::methods`]) rather than threading the three
1107/// borrows separately. Methods are exposed as [`MethodHandler`]s via [`Self::methods`].
1108pub(crate) struct ServiceHandler<'a> {
1109    pub(crate) plan: &'a ServicePlan,
1110    pub(crate) metadata: &'a CodeGenMetadata,
1111    pub(crate) config: &'a CodeGenConfig,
1112}
1113
1114impl<'a> ServiceHandler<'a> {
1115    pub(crate) fn new(
1116        plan: &'a ServicePlan,
1117        metadata: &'a CodeGenMetadata,
1118        config: &'a CodeGenConfig,
1119    ) -> Self {
1120        Self {
1121            plan,
1122            metadata,
1123            config,
1124        }
1125    }
1126
1127    /// A handler per service of `plan`, in plan order.
1128    pub(crate) fn for_plan(
1129        plan: &'a GenerationPlan,
1130        metadata: &'a CodeGenMetadata,
1131        config: &'a CodeGenConfig,
1132    ) -> Vec<Self> {
1133        plan.services
1134            .iter()
1135            .map(|service| Self::new(service, metadata, config))
1136            .collect()
1137    }
1138
1139    /// The handler of another service, with this one's metadata and config.
1140    pub(crate) fn with_plan(&self, plan: &'a ServicePlan) -> Self {
1141        Self::new(plan, self.metadata, self.config)
1142    }
1143}
1144
1145impl ServiceHandler<'_> {
1146    pub(crate) fn resource(&self) -> Option<&ManagedResource> {
1147        self.plan.managed_resources.first()
1148    }
1149
1150    /// Whether this service manages a `google.api.resource` and therefore gets a resource-scoped
1151    /// client (e.g. `CatalogClient` bound to a name).
1152    ///
1153    /// Resource-less services (entity tag assignments, temporary credentials, delta commits) have
1154    /// no scoped client; their methods are emitted on the root client and pass every param
1155    /// directly ([`BindingMode::Flat`]). See [`Self::binding_mode`].
1156    pub(crate) fn is_resource_scoped(&self) -> bool {
1157        self.resource().is_some()
1158    }
1159
1160    /// The [`BindingMode`] used to lower this service's methods in language bindings.
1161    ///
1162    /// A resource service whose identity can't be modeled by a string-component scoped client
1163    /// (see [`Self::supports_scoped_client`]) is lowered flat, exactly like a resource-less
1164    /// service: every method lives on the root client and passes all params directly.
1165    pub(crate) fn binding_mode(&self) -> BindingMode {
1166        if self.emits_scoped_binding() {
1167            BindingMode::Scoped
1168        } else {
1169            BindingMode::Flat
1170        }
1171    }
1172
1173    /// Whether language bindings emit a per-service scoped client module + accessor for this
1174    /// service. True only for resource services whose identity a string-component scoped client
1175    /// can express; others are lowered flat onto the root client. See [`Self::binding_mode`].
1176    pub(crate) fn emits_scoped_binding(&self) -> bool {
1177        self.is_resource_scoped() && self.supports_scoped_client()
1178    }
1179
1180    /// The language-agnostic spec for this service's resource accessor on the aggregate client, or
1181    /// `None` for resource-less services.
1182    ///
1183    /// All four accessor emitters (Rust aggregate, Python, Node, TypeScript) render from this single
1184    /// spec rather than re-deriving the param list and nesting decision, so they can't diverge.
1185    pub(crate) fn accessor_spec(&self) -> Option<AccessorSpec> {
1186        let resource = self.resource()?;
1187        let params = self.plan.resource_accessor_params.clone()?;
1188        // A resource is nested when its accessor params include parent components beyond its own
1189        // name; its full name is then the dot-joined components. This is the single nesting rule
1190        // of every accessor emitter.
1191        let nested = params.len() > 1;
1192        Some(AccessorSpec {
1193            singular: resource.descriptor.singular.clone(),
1194            params,
1195            nested,
1196        })
1197    }
1198
1199    pub(crate) fn methods(&self) -> impl Iterator<Item = MethodHandler<'_>> {
1200        self.plan.methods.iter().map(|plan| MethodHandler {
1201            plan,
1202            metadata: self.metadata,
1203            config: self.config,
1204        })
1205    }
1206
1207    /// The methods that have a `google.api.http` route — the ones the REST emitters (client,
1208    /// handler, server, aggregate) can generate. Routeless methods (no HTTP annotation) are emitted
1209    /// only by the ConnectRPC client, which iterates [`Self::methods`] directly.
1210    pub(crate) fn rest_methods(&self) -> impl Iterator<Item = MethodHandler<'_>> {
1211        self.methods().filter(|m| m.plan.has_http_route)
1212    }
1213
1214    /// The ergonomic, public-facing client type for this service.
1215    ///
1216    /// For a resource-scoped service this is the **scoped resource client** (e.g. `CatalogClient`,
1217    /// bound to a name) — the type callers and language bindings use. For a resource-less service it
1218    /// is the single flat client (e.g. `TagAssignmentClient`). The low-level transport client (one
1219    /// async method per RPC) is [`Self::low_level_client_type`].
1220    pub(crate) fn client_type(&self) -> Ident {
1221        if let Some(resource) = self.resource() {
1222            format_ident!(
1223                "{}",
1224                format!("{} client", resource.descriptor.singular).to_case(Case::Pascal)
1225            )
1226        } else {
1227            format_ident!(
1228                "{}Client",
1229                self.plan
1230                    .service_name
1231                    .trim_end_matches("Service")
1232                    .trim_end_matches('s')
1233            )
1234        }
1235    }
1236
1237    /// The low-level transport client type (`{ client: CloudClient, base_url: Url }` with one async
1238    /// method per RPC), emitted by [`client`](super::client).
1239    ///
1240    /// For a resource-scoped service this is `<Singular>ServiceClient` (e.g. `CatalogServiceClient`),
1241    /// distinct from the ergonomic scoped [`Self::client_type`] (`CatalogClient`). For a
1242    /// resource-less service there is no scoped client, so the low-level client *is* the
1243    /// [`Self::client_type`] (e.g. `TagAssignmentClient`).
1244    pub(crate) fn low_level_client_type(&self, protocol: crate::codegen::ClientProtocol) -> Ident {
1245        // ConnectRPC: a single adapter type per service, named deterministically from the proto
1246        // service so it never collides with (nor is confused for) the connect-rust `{Service}Client`
1247        // it wraps. Connect has no scoped resource client, so this is always the low-level type.
1248        if protocol == crate::codegen::ClientProtocol::Connect {
1249            return format_ident!(
1250                "{}ConnectClient",
1251                self.plan.service_name.to_case(Case::Pascal)
1252            );
1253        }
1254        if let Some(resource) = self.resource() {
1255            format_ident!(
1256                "{}ServiceClient",
1257                resource.descriptor.singular.to_case(Case::Pascal)
1258            )
1259        } else {
1260            self.client_type()
1261        }
1262    }
1263
1264    /// The connect-rust generated client type for this service, as a fully-qualified path.
1265    ///
1266    /// Resolves to `<connect_client_path>::<ServiceName>Client` (e.g.
1267    /// `headwaters_proto::connect_gen::headwaters::read::v1::ReadServiceClient`). Used only in
1268    /// [`ClientProtocol::Connect`](crate::codegen::ClientProtocol) mode; errors if
1269    /// `connect_client_path` is unset.
1270    pub(crate) fn connect_inner_client_path(&self) -> crate::Result<syn::Path> {
1271        let base = self.config.connect_client_path.as_deref().ok_or_else(|| {
1272            crate::Error::Build(
1273                "client_protocol = Connect requires `connect_client_path` to be set".to_string(),
1274            )
1275        })?;
1276        let full = format!(
1277            "{base}::{}Client",
1278            self.plan.service_name.to_case(Case::Pascal)
1279        );
1280        syn::parse_str(&full).map_err(|source| crate::error::Error::InvalidRustPath {
1281            path: full.clone(),
1282            message: "connect_client_path + service client name".to_string(),
1283            source,
1284        })
1285    }
1286
1287    /// The ergonomic scoped resource client type, or `None` for a resource-less service.
1288    ///
1289    /// Same as [`Self::client_type`] but `Option`-typed to make "resource-scoped only" explicit at
1290    /// call sites that generate the scoped client / its accessor.
1291    pub(crate) fn scoped_client_type(&self) -> Option<Ident> {
1292        self.resource().map(|_| self.client_type())
1293    }
1294
1295    /// Whether this service's resource can be modeled by an ergonomic resource-scoped client.
1296    ///
1297    /// The scoped client (and the aggregate client's `<singular>(…)` accessor) captures a
1298    /// resource's identity as an ordered list of *string* name components and fills each method's
1299    /// path parameters from them. A resource whose scoped instance methods carry a non-string path
1300    /// parameter — e.g. a model version addressed by its parent's composite `{full_name}` plus an
1301    /// integer `{version}` — cannot be expressed that way, so no scoped client is emitted for it
1302    /// (its methods remain available on the low-level service client). Returns `false` in that case.
1303    pub(crate) fn supports_scoped_client(&self) -> bool {
1304        !self
1305            .methods()
1306            .filter(|m| m.is_scoped_instance_method())
1307            .any(|m| {
1308                m.required_parameters().any(|p| {
1309                    p.is_path_param()
1310                        && !matches!(
1311                            p.field_type().base_type,
1312                            crate::parsing::types::BaseType::String
1313                        )
1314                })
1315            })
1316    }
1317
1318    /// The module segment under which this service's generated models live.
1319    ///
1320    /// Models are emitted per proto *package* (e.g. `example.tags.v1` → module `tags`),
1321    /// which is not always the same as the service's `base_path` (derived from the service
1322    /// *name*, e.g. `TagPoliciesService` → `tag_policies`). Use the package's leaf-before-version
1323    /// segment so the models import path matches where the proto plugin actually wrote the types.
1324    /// Falls back to `base_path` when the package is empty or unparsable.
1325    fn models_segment(&self) -> String {
1326        package_models_segment(&self.plan.package)
1327            .map_or_else(|| self.plan.base_path.clone(), str::to_string)
1328    }
1329
1330    /// The version segment under which this service's generated models live; see
1331    /// [`package_models_version`].
1332    fn models_version(&self) -> String {
1333        package_models_version(&self.plan.package).to_string()
1334    }
1335
1336    pub(crate) fn models_path(&self) -> syn::Path {
1337        // Templates (and each service's substitution) are validated by `generate_code` before
1338        // any `ServiceHandler` is used, so skip the redundant re-validation `new` would do.
1339        ModelsPath::from_template(&self.config.models_path_template)
1340            .resolve(&self.models_segment(), &self.models_version())
1341    }
1342
1343    /// `use` globs for the models of other packages that this service's RPCs take or return.
1344    pub(crate) fn foreign_models_imports(&self) -> Vec<TokenStream> {
1345        let template = ModelsPath::from_template(&self.config.models_path_template);
1346        self.methods()
1347            .flat_map(|m| {
1348                [
1349                    m.plan.metadata.input_type.as_str(),
1350                    m.plan.metadata.output_type.as_str(),
1351                ]
1352            })
1353            .filter_map(message_package)
1354            .filter(|package| *package != self.plan.package && !package.starts_with("google."))
1355            .unique()
1356            .filter_map(|package| {
1357                let segment = package_models_segment(&package)?;
1358                let path = template.resolve(segment, package_models_version(&package));
1359                Some(quote! { use #path::*; })
1360            })
1361            .collect()
1362    }
1363
1364    pub(crate) fn models_path_crate(&self) -> syn::Path {
1365        ModelsPath::from_template(&self.config.models_path_crate_template)
1366            .resolve(&self.models_segment(), &self.models_version())
1367    }
1368
1369    /// The `#[cfg(feature = "<model package>")]` gate for this service's client items, or no
1370    /// tokens when [`RestClientOptions::resource_features`] is off.
1371    ///
1372    /// The feature is named after the model package segment (see [`Self::models_segment`]), so
1373    /// services sharing a package (e.g. two tag services over `tags.v1`) share a feature.
1374    pub(crate) fn client_feature_cfg(&self) -> TokenStream {
1375        if self.config.rest_client.resource_features {
1376            let feature = self.models_segment();
1377            quote! { #[cfg(feature = #feature)] }
1378        } else {
1379            quote! {}
1380        }
1381    }
1382
1383    /// The models *root* module path — the prefix of [`Self::models_path`] before the
1384    /// per-service/version segments (e.g. `example_common::models` for the template
1385    /// `example_common::models::{service}::v1`).
1386    ///
1387    /// The `Py*` wrapper `#[pyclass]` types are re-exported here (see
1388    /// `generate_models_mod`), so the Python bindings import them from this path.
1389    /// Returns `None` if the template has no `{service}` placeholder to split on.
1390    pub(crate) fn models_root_path(&self) -> Option<syn::Path> {
1391        let models_path = ModelsPath::from_template(&self.config.models_path_template);
1392        syn::parse_str(models_path.root()?).ok()
1393    }
1394}
1395
1396/// The package of a fully qualified message name (`.my.shares.v1.ShareInfo` → `my.shares.v1`):
1397/// the lowercase segments before the message names.
1398fn message_package(type_name: &str) -> Option<String> {
1399    let segments: Vec<&str> = type_name
1400        .trim_start_matches('.')
1401        .split('.')
1402        .take_while(|s| s.starts_with(|c: char| c.is_ascii_lowercase()))
1403        .collect();
1404    (!segments.is_empty()).then(|| segments.join("."))
1405}
1406
1407/// The models module segment of a proto package: the one before the version (`my.tags.v1` →
1408/// `tags`), else the last (`my.tags` → `tags`).
1409pub(crate) fn package_models_segment(package: &str) -> Option<&str> {
1410    let segs: Vec<&str> = package.split('.').filter(|s| !s.is_empty()).collect();
1411    match segs.as_slice() {
1412        [.., name, version] if is_version_segment(version) => Some(name),
1413        [.., name] => Some(name),
1414        [] => None,
1415    }
1416}
1417
1418/// The version segment of a proto package (`my.tags.v1` → `v1`, `my.agents.v0alpha1` →
1419/// `v0alpha1`); `v1` when it has none.
1420pub(crate) fn package_models_version(package: &str) -> &str {
1421    let segs: Vec<&str> = package.split('.').filter(|s| !s.is_empty()).collect();
1422    match segs.as_slice() {
1423        [.., _name, version] if is_version_segment(version) => version,
1424        _ => "v1",
1425    }
1426}
1427
1428/// Whether a proto package segment is a version marker like `v1`, `v2beta1`, etc.
1429fn is_version_segment(seg: &str) -> bool {
1430    seg.strip_prefix('v')
1431        .is_some_and(|rest| rest.starts_with(|c: char| c.is_ascii_digit()))
1432}
1433
1434/// The per-method counterpart of [`ServiceHandler`], scoped to a single RPC.
1435///
1436/// Pairs one analyzed [`MethodPlan`](crate::analysis::MethodPlan) with the same [`CodeGenMetadata`]
1437/// and [`CodeGenConfig`] borrows, and exposes the per-method derivations the generators rely on:
1438/// input/output message lookup, builder and binding method names, parameter plans
1439/// ([`Self::param_plan`]), and the language-agnostic [`EmitShape`](crate::analysis::EmitShape).
1440/// Yielded by [`ServiceHandler::methods`]; each generator iterates these to emit one unit of code
1441/// (a trait method, a client method, a builder, …) per RPC.
1442pub(crate) struct MethodHandler<'a> {
1443    plan: &'a MethodPlan,
1444    metadata: &'a CodeGenMetadata,
1445    config: &'a CodeGenConfig,
1446}
1447
1448impl MethodHandler<'_> {
1449    /// The query-string key of `param` (see [`QueryParam::key`]).
1450    pub(crate) fn query_key<'p>(
1451        &self,
1452        param: &'p crate::analysis::QueryParam,
1453    ) -> std::borrow::Cow<'p, str> {
1454        let input = self.plan.metadata.input_type.trim_start_matches('.');
1455        let json_names = self
1456            .config
1457            .rest_client
1458            .json_query_packages
1459            .iter()
1460            .any(|package| {
1461                input
1462                    .strip_prefix(package.as_str())
1463                    .is_some_and(|rest| rest.starts_with('.'))
1464            });
1465        param.key(json_names)
1466    }
1467
1468    pub(crate) fn is_collection_method(&self) -> bool {
1469        crate::analysis::is_collection_method(
1470            &self.plan.request_type,
1471            &self.plan.metadata.method_name,
1472        )
1473    }
1474
1475    /// The language-agnostic [`EmitShape`] for this method, derived once from its request type and
1476    /// name. The Python/Node/TypeScript emitters branch on this instead of each re-matching
1477    /// `RequestType`.
1478    pub(crate) fn emit_shape(&self) -> crate::analysis::EmitShape {
1479        crate::analysis::emit_shape(&self.plan.request_type, self.is_collection_method())
1480    }
1481
1482    /// Whether this method is emitted as an instance method on a resource-scoped client
1483    /// (`get`/`update`/`delete` or a resource-targeted custom `POST`/`PATCH`).
1484    ///
1485    /// The set is standard `Get`/`Update`/`Delete`, plus a non-collection custom `POST`/`PATCH`
1486    /// (e.g. `POST /catalogs/{name}:rotateToken`). Other custom verbs (a custom `GET`/`DELETE`) are
1487    /// deliberately **not** surfaced on the scoped client.
1488    pub(crate) fn is_scoped_instance_method(&self) -> bool {
1489        // Standard instance verbs are always scoped.
1490        if matches!(
1491            self.plan.request_type,
1492            RequestType::Get | RequestType::Update | RequestType::Delete
1493        ) {
1494            return true;
1495        }
1496        // A *resource-targeted* custom RPC (any verb) is a scoped instance method: it has at least
1497        // one path param (so it operates on a specific resource) and is not a collection method
1498        // (those — list / create / factory — live on the root client). This surfaces custom reads
1499        // like `GetTableExists` / `GetPermissions` (`GET …/{name}/exists`) and resource-targeted
1500        // custom POST/PATCH (`…/{name}:rotateToken`) on the scoped client, rather than leaving their
1501        // generated builders orphaned/unreachable.
1502        matches!(self.plan.request_type, RequestType::Custom(_))
1503            && !self.is_collection_method()
1504            && self.plan.path_parameters().next().is_some()
1505    }
1506
1507    /// The ordered binding parameter list for `mode`, with shared filtering applied once.
1508    ///
1509    /// Required params come before optional ones (the
1510    /// `required_parameters().chain(optional_parameters())` order). Filtering is keyed on
1511    /// [`EmitShape`]:
1512    ///
1513    /// - **collection shapes** (`List`, `Create`): never drop path params (collection methods live
1514    ///   on the root client and pass everything); drop `page_token` for `List` (streaming handles
1515    ///   pagination).
1516    /// - **instance shapes** (`GetUpdate`, `Delete`): drop path params in [`BindingMode::Scoped`]
1517    ///   (the scoped client already holds them); never filter `page_token`.
1518    ///
1519    /// Per-language emitters render the returned `&RequestParam`s (applying their own type mapping /
1520    /// capability filters) instead of each re-deriving this filtered list.
1521    pub(crate) fn param_plan(&self, mode: BindingMode) -> Vec<&RequestParam> {
1522        let (required, optional) = self.param_plan_split(mode);
1523        required.into_iter().chain(optional).collect()
1524    }
1525
1526    /// Like [`Self::param_plan`] but keeps the required/optional partition, for emitters (e.g.
1527    /// TypeScript) that render the two groups differently (required positional args vs an optional
1528    /// `options` object). Both groups have the same shared path/`page_token` filtering applied.
1529    pub(crate) fn param_plan_split(
1530        &self,
1531        mode: BindingMode,
1532    ) -> (Vec<&RequestParam>, Vec<&RequestParam>) {
1533        use crate::analysis::EmitShape;
1534        let shape = self.emit_shape();
1535        let is_collection_shape = matches!(shape, EmitShape::List | EmitShape::Create);
1536        let drop_path = !is_collection_shape && mode == BindingMode::Scoped;
1537        let drop_page_token = shape == EmitShape::List;
1538        let keep = |p: &&RequestParam| {
1539            let is_dropped_path = drop_path && p.is_path_param();
1540            let is_dropped_page_token = drop_page_token && p.name() == "page_token";
1541            !is_dropped_path && !is_dropped_page_token
1542        };
1543        let required = self.required_parameters().filter(keep).collect();
1544        let optional = self.optional_parameters().filter(keep).collect();
1545        (required, optional)
1546    }
1547
1548    pub(crate) fn output_message(&self) -> Option<&MessageInfo> {
1549        if self.plan.metadata.output_type.ends_with("Empty") {
1550            return None;
1551        }
1552        self.metadata.messages.get(&self.plan.metadata.output_type)
1553    }
1554
1555    pub(crate) fn output_type(&self) -> Option<Ident> {
1556        self.output_message().map(|t| extract_type_ident(&t.name))
1557    }
1558
1559    /// The success type of the handler-trait method: the output message, or `()` without a
1560    /// response.
1561    pub(crate) fn handler_output_type(&self) -> TokenStream {
1562        if self.plan.has_response {
1563            let output = self.output_type();
1564            quote! { #output }
1565        } else {
1566            quote! { () }
1567        }
1568    }
1569
1570    /// The type a client call resolves to: the output message, a `Vec` of them for a
1571    /// server-streaming method, `bool` for an existence check, or `()`.
1572    pub(crate) fn result_type(&self) -> TokenStream {
1573        match self.output_type() {
1574            Some(output) if self.plan.metadata.server_streaming => quote! { Vec<#output> },
1575            Some(output) => quote! { #output },
1576            None if self.is_existence_check() => quote! { bool },
1577            None => quote! { () },
1578        }
1579    }
1580
1581    /// A `HEAD` request without an output: it returns `bool`, `false` for a 404.
1582    pub(crate) fn is_existence_check(&self) -> bool {
1583        self.plan.http_method.eq_ignore_ascii_case("head") && self.output_type().is_none()
1584    }
1585
1586    /// The output fields that come from HTTP response headers instead of the body.
1587    pub(crate) fn response_header_fields(&self) -> Vec<&MessageField> {
1588        self.output_message()
1589            .map(|output| {
1590                output
1591                    .fields
1592                    .iter()
1593                    .filter(|f| f.response_header.is_some())
1594                    .collect()
1595            })
1596            .unwrap_or_default()
1597    }
1598
1599    pub(crate) fn list_output_field(&self) -> Option<&MessageField> {
1600        self.output_message()?
1601            .fields
1602            .iter()
1603            .find(|f| !f.name.contains("page_token"))
1604    }
1605
1606    pub(crate) fn input_message(&self) -> Option<&MessageInfo> {
1607        if self.plan.metadata.input_type == "Empty" {
1608            return None;
1609        }
1610        self.metadata.messages.get(&self.plan.metadata.input_type)
1611    }
1612
1613    pub(crate) fn input_type(&self) -> Option<Ident> {
1614        self.input_message().map(|t| extract_type_ident(&t.name))
1615    }
1616
1617    pub(crate) fn builder_type(&self) -> Ident {
1618        format_ident!("{}Builder", self.plan.metadata.method_name)
1619    }
1620
1621    /// The method name to expose in language bindings.
1622    ///
1623    /// Prefers the gnostic `operation_id` annotation (`gnostic.openapi.v3.operation`) when present,
1624    /// snake-cased, so proto authors can give bindings a hand-tuned name. Falls back to the
1625    /// proto-derived name ([`MethodPlan::base_method_ident`]).
1626    ///
1627    /// This affects only the *emitted* binding method name — the call into the underlying Rust
1628    /// client still uses [`MethodPlan::base_method_ident`], so the handler/client API is unchanged.
1629    pub(crate) fn binding_method_name(&self) -> Ident {
1630        format_ident!("{}", self.binding_method_name_str())
1631    }
1632
1633    /// The snake_case binding method name (see [`Self::binding_method_name`]).
1634    ///
1635    /// Provided as a `String` so the string-templated TypeScript generator can camel-case it and
1636    /// stay consistent with the snake→camel name NAPI derives for the native method.
1637    pub(crate) fn binding_method_name_str(&self) -> String {
1638        match self.plan.metadata.operation.as_ref() {
1639            Some(op) if !op.operation_id.is_empty() => operation_to_method_name(&op.operation_id),
1640            _ => self.plan.handler_function_name.clone(),
1641        }
1642    }
1643
1644    /// The camelCase binding method name JS sees (the name NAPI and `js_name` derive from
1645    /// [`Self::binding_method_name_str`]).
1646    pub(crate) fn js_method_name(&self) -> String {
1647        self.binding_method_name_str().to_case(Case::Camel)
1648    }
1649
1650    /// Get type representation for rust depending on context
1651    ///
1652    /// Depending on context we may want concrete types (e.g. 'String') or more flexible types (e.g. 'Into<String d>')
1653    pub(crate) fn field_type(&self, field_type: &UnifiedType, ctx: RenderContext) -> syn::Type {
1654        let rust_type = types::unified_to_rust(field_type, ctx, self.config.runtime);
1655        // `rust_type` comes from this crate's own proto→Rust mapping, not user input, so an
1656        // unparsable result is a bug in `unified_to_rust`. Panic with the offending string so
1657        // the broken mapping is obvious (the generated-code parse test in
1658        // `tests/generation_integration.rs` is the broader guard for output validity).
1659        syn::parse_str(&rust_type).unwrap_or_else(|e| {
1660            panic!("internal: unified_to_rust produced an invalid type `{rust_type}`: {e}")
1661        })
1662    }
1663
1664    /// Get field assignment TokenStream for constructor
1665    pub(crate) fn field_assignment(
1666        &self,
1667        field_type: &UnifiedType,
1668        field_ident: &proc_macro2::Ident,
1669        ctx: &RenderContext,
1670    ) -> TokenStream {
1671        types::field_assignment(field_type, field_ident, ctx, self.config.runtime)
1672    }
1673
1674    pub(crate) fn required_parameters(&self) -> impl Iterator<Item = &RequestParam> {
1675        self.plan
1676            .parameters
1677            .iter()
1678            .filter(|param| !param.is_optional())
1679    }
1680
1681    pub(crate) fn optional_parameters(&self) -> impl Iterator<Item = &RequestParam> {
1682        self.plan
1683            .parameters
1684            .iter()
1685            .filter(|param| param.is_optional())
1686    }
1687
1688    /// Split body fields into required and optional subsets.
1689    pub(crate) fn split_body_fields(&self) -> (Vec<&BodyField>, Vec<&BodyField>) {
1690        split_body_fields(self.plan)
1691    }
1692}
1693
1694/// Extract the final type name from a fully qualified protobuf type and convert to Ident.
1695///
1696/// Thin `Ident` adapter over [`crate::utils::extract_simple_type_name`] — the single source of
1697/// truth for last-segment extraction. (Not [`crate::utils::extract_qualified_type_name`], which
1698/// parent-prefixes nested types — different behavior.)
1699pub(crate) fn extract_type_ident(full_type: &str) -> Ident {
1700    format_ident!("{}", crate::utils::extract_simple_type_name(full_type))
1701}
1702
1703#[cfg(test)]
1704mod tests {
1705    use super::message_package;
1706
1707    #[test]
1708    fn message_package_stops_at_the_message_name() {
1709        assert_eq!(
1710            message_package(".unitycatalog.shares.v1.ShareInfo").as_deref(),
1711            Some("unitycatalog.shares.v1")
1712        );
1713        assert_eq!(
1714            message_package("demo.v1.Outer.Inner").as_deref(),
1715            Some("demo.v1")
1716        );
1717        assert_eq!(message_package("Empty"), None);
1718    }
1719}