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