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