Skip to main content

olai_codegen/codegen/
config.rs

1//! Configuration types for code generation.
2//!
3//! Split out of [`super`] so the parent module focuses on generation orchestration.
4//! Re-exported from [`super`] (and `crate`) so the public API path is unchanged.
5
6use std::path::PathBuf;
7
8use crate::error::{Error, Result};
9
10/// Wire protocol a *generated* client speaks.
11///
12/// A single client layer. The set of layers to emit is [`ClientProtocols`]; a server that exposes
13/// both REST and ConnectRPC for the same proto can emit both from one generation run.
14///
15/// - [`ClientProtocol::Rest`]: the HTTP/JSON low-level client (the `client` module) plus fluent
16///   builders. Methods are routed from `google.api.http`; a method without an annotation has no
17///   route and is skipped by the REST emitter.
18/// - [`ClientProtocol::Connect`]: fluent builders over the **connect-rust** (BSR plugin) generated
19///   `{Service}Client<T>`, dispatched via [`connectrpc`](https://docs.rs/connectrpc). No
20///   `google.api.http` annotation is required — the whole request message is the body, and
21///   required-vs-optional partitioning comes purely from `google.api.field_behavior`.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
23pub enum ClientProtocol {
24    /// HTTP/JSON client routed from `google.api.http` (the historical default).
25    #[default]
26    Rest,
27    /// ConnectRPC client layered over the connect-rust generated service client.
28    Connect,
29}
30
31impl ClientProtocol {
32    /// The convention subdirectory name for this protocol's client when both are emitted side by
33    /// side (`<service>/rest/`, `<service>/connect/`).
34    pub(crate) fn subdir(self) -> &'static str {
35        match self {
36            ClientProtocol::Rest => "rest",
37            ClientProtocol::Connect => "connect",
38        }
39    }
40}
41
42/// Which client protocol layer(s) to emit.
43///
44/// Analysis is protocol-agnostic (REST analysis is a superset of what ConnectRPC needs), so this is
45/// purely an *emit-time* selection. Emitting both is supported: a server fronting the same proto
46/// over REST and ConnectRPC can hand out either client.
47///
48/// File layout:
49/// - exactly one protocol: emitted **flat** under `<service>/` (`<service>/client.rs`,
50///   `<service>/builders.rs`) — unchanged from single-protocol output.
51/// - both: each protocol gets its own convention subdir (`<service>/rest/`, `<service>/connect/`),
52///   re-exported from `<service>/mod.rs`.
53///
54/// Defaults to REST only, so existing output is unchanged.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub struct ClientProtocols {
57    /// Emit the REST (HTTP/JSON) client.
58    pub rest: bool,
59    /// Emit the ConnectRPC client.
60    pub connect: bool,
61}
62
63impl Default for ClientProtocols {
64    fn default() -> Self {
65        Self {
66            rest: true,
67            connect: false,
68        }
69    }
70}
71
72impl ClientProtocols {
73    /// Whether more than one protocol is selected (drives the subdir-vs-flat layout decision).
74    pub(crate) fn is_multi(self) -> bool {
75        self.rest && self.connect
76    }
77
78    /// The selected protocols, in deterministic order (REST before Connect).
79    pub(crate) fn selected(self) -> Vec<ClientProtocol> {
80        let mut v = Vec::new();
81        if self.rest {
82            v.push(ClientProtocol::Rest);
83        }
84        if self.connect {
85            v.push(ClientProtocol::Connect);
86        }
87        v
88    }
89}
90
91/// Protobuf runtime that the *generated* code is expected to consume.
92///
93/// This selects the ABI that emitted clients, handlers, and builders are shaped for —
94/// i.e. how generated code reads and writes the consuming project's model types. It does
95/// **not** affect how `olai-codegen` itself parses descriptors.
96///
97/// - [`Runtime::Prost`] (default): models are [prost](https://docs.rs/prost)-generated —
98///   open enums are bare `i32`, singular message fields are `Option<Box<T>>`.
99/// - [`Runtime::Buffa`]: models are [buffa](https://github.com/anthropics/buffa)-generated —
100///   open enums are `EnumValue<E>`, singular message fields are `MessageField<T>`, and the
101///   runtime provides native serde JSON (no separate `pbjson` layer).
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
103pub enum Runtime {
104    /// prost-generated models (the historical default).
105    #[default]
106    Prost,
107    /// buffa-generated models.
108    Buffa,
109}
110
111/// Validated model import path derived from a `{service}` template string.
112///
113/// Constructed once from [`CodeGenConfig`] template fields. `resolve` performs the
114/// `{service}` substitution and parses the result as a [`syn::Path`], catching
115/// malformed templates at construction time rather than at code-generation time.
116#[derive(Debug, Clone)]
117pub struct ModelsPath {
118    template: String,
119}
120
121impl ModelsPath {
122    /// Build a `ModelsPath` from a template string containing `{service}` and,
123    /// optionally, `{version}`.
124    ///
125    /// Performs a test substitution at construction to validate the template.
126    pub fn new(template: &str) -> Result<Self> {
127        let test = template
128            .replace("{service}", "test")
129            .replace("{version}", "v1");
130        syn::parse_str::<syn::Path>(&test).map_err(|e| Error::InvalidModelsPathTemplate {
131            template: template.to_string(),
132            source: e,
133        })?;
134        Ok(Self::from_template(template))
135    }
136
137    /// Wrap a template without the construction-time validation done by [`ModelsPath::new`].
138    ///
139    /// For callers that already validated the template (e.g. `generate_code` validates both
140    /// templates up front, so per-service handlers needn't re-parse on every `models_path()`
141    /// call). `resolve`/`try_resolve` still parse the final substituted path.
142    pub(crate) fn from_template(template: &str) -> Self {
143        Self {
144            template: template.to_string(),
145        }
146    }
147
148    /// Replace `{service}` with `service` and return the parsed [`syn::Path`], erroring if the
149    /// substitution does not parse.
150    ///
151    /// `new` only proves the template parses for the literal `test`; a real service segment that
152    /// isn't a valid path component (leading digit, hyphen, reserved word) can still fail here.
153    /// Call this once per service up front (see [`ModelsPath::validate_for`]) so failures surface
154    /// as a clean [`Error::InvalidModelsPathTemplate`] before generation rather than panicking.
155    pub fn try_resolve(&self, service: &str, version: &str) -> Result<syn::Path> {
156        let path = self.substitute(service, version);
157        syn::parse_str(&path).map_err(|source| Error::InvalidModelsPathTemplate {
158            template: path,
159            source,
160        })
161    }
162
163    /// The template prefix before `{service}`, without the trailing `::` (`crate::models` for
164    /// `crate::models::{service}::v1`). `None` when there is none.
165    pub(crate) fn root(&self) -> Option<&str> {
166        let (prefix, _) = self.template.split_once("{service}")?;
167        let root = prefix.trim_end_matches(':');
168        (!root.is_empty()).then_some(root)
169    }
170
171    /// Validate that `service` substitutes into a parseable path. Used up front, per service.
172    ///
173    /// Validates against the conventional `v1` version; the actual version segment is a plain
174    /// path component derived from the proto package, so it cannot make a `{service}`-valid
175    /// template fail to parse.
176    pub fn validate_for(&self, service: &str) -> Result<()> {
177        self.try_resolve(service, "v1").map(|_| ())
178    }
179
180    /// Replace `{service}`/`{version}` and return the parsed [`syn::Path`].
181    ///
182    /// # Panics
183    ///
184    /// Panics if the substituted path doesn't parse. Generation validates every service segment
185    /// up front via [`ModelsPath::validate_for`], so this is unreachable on the generation path;
186    /// prefer [`ModelsPath::try_resolve`] anywhere that hasn't already validated.
187    pub fn resolve(&self, service: &str, version: &str) -> syn::Path {
188        let path = self.substitute(service, version);
189        syn::parse_str(&path)
190            .unwrap_or_else(|e| panic!("Invalid models path `{path}` after substitution: {e}"))
191    }
192
193    /// Substitute the `{service}` and `{version}` placeholders. A template without `{version}`
194    /// is left as-is for that placeholder, preserving any literal version it already encodes.
195    fn substitute(&self, service: &str, version: &str) -> String {
196        self.template
197            .replace("{service}", service)
198            .replace("{version}", version)
199    }
200}
201
202/// Configuration for language-binding code generation (Python / Node.js).
203#[derive(Debug, Clone)]
204pub struct BindingsConfig {
205    /// Name of the aggregate client struct (e.g. `"MyServiceClient"`).
206    pub aggregate_client_name: String,
207    /// Rust crate name used in `use` statements for the client crate
208    /// (e.g. `"my_service_client"`).
209    ///
210    /// This must be set explicitly because the crate name may not match the
211    /// snake_case form of `aggregate_client_name` (e.g. `"MyServiceClient"`
212    /// snake_cases to `"my_service_client"`).
213    pub client_crate_name: String,
214    /// Fully-qualified Python error type (e.g. `"PyMyServiceError"`).
215    pub py_error_type: String,
216    /// Fully-qualified Python result alias (e.g. `"PyMyServiceResult"`).
217    pub py_result_type: String,
218    /// Name of the NAPI error extension trait (e.g. `"NapiErrorExt"`).
219    pub napi_error_ext_trait: String,
220    /// Base class name for TypeScript errors (e.g. `"MyServiceError"`).
221    pub ts_error_base_class: String,
222    /// Prefix used in native NAPI error messages (e.g. `"UC"`).
223    pub ts_error_code_prefix: String,
224    /// The Python API. Required with the `python` output; see [`PythonOptions`].
225    pub python: Option<PythonOptions>,
226}
227
228/// Shape of the generated Python (PyO3) API.
229///
230/// The model classes are frozen, `eq`, with a Python-style `repr`, and only the models that a
231/// request takes get a keyword constructor. Enums are `enum.StrEnum` classes (Python 3.11+) in
232/// `<package>/_enums.py`, without the zero `*_UNSPECIFIED` member. The extension module is a
233/// declarative `#[pymodule]` with the proto comments as docs, so PyO3's introspection
234/// (`experimental-inspect`) writes the `.pyi`. The methods block on the Tokio runtime of the
235/// generated `codegen/runtime.rs` (needs `tokio`), which the module also installs for the
236/// `_async` methods. Client errors raise the PyO3 exception classes of the generated
237/// `codegen/error.rs`, which read them through its `ApiErrorAnswer` trait: implemented there for
238/// the error of [`RestClientOptions::error_module`], else by the binding crate.
239#[derive(Debug, Clone)]
240pub struct PythonOptions {
241    /// The pure-Python package that gets `_enums.py` and the public modules.
242    pub package: PyPackageConfig,
243    /// Name of the base exception class (e.g. `MyServiceError`): the class with `message`,
244    /// `code`, `status` and `details` that the subclasses per kind of HTTP error status extend.
245    pub error_base_class: String,
246    /// The extension module (e.g. `my_pkg._core`): the generated `#[pymodule]`. Classes of a
247    /// package `a.<service>.v1` go in the submodule `<module>.<service>`, the root client and
248    /// the exceptions in `<module>`.
249    pub module: String,
250    /// Every client method also gets a `<name>_async` twin returning an `asyncio.Future`. Needs
251    /// `pyo3-async-runtimes` with `tokio-runtime`.
252    pub async_methods: bool,
253    /// Optional parameters are keyword-only; constructors take `REQUIRED` fields positionally.
254    pub keyword_only: bool,
255    /// Write `<package>/<service>.py` per submodule of [`module`](Self::module), re-exporting
256    /// its classes and enums.
257    pub public_modules: bool,
258    /// The `User-Agent` product of the Python client (`<product>/<binding crate version>`).
259    /// Needs the `reqwest` transport.
260    pub user_agent: Option<String>,
261    /// Hand-written classes and functions of the binding crate (Rust paths, e.g.
262    /// `crate::extras::PyHelper`) that the extension module also exports.
263    pub exports: Vec<String>,
264}
265
266/// Where the generated pure-Python files go.
267#[derive(Debug, Clone)]
268pub struct PyPackageConfig {
269    /// Directory of the Python package (e.g. `python/my_pkg`).
270    pub dir: PathBuf,
271    /// Import name of the package (e.g. `my_pkg`).
272    pub name: String,
273}
274
275/// Configuration for code generation, including import paths and output directories.
276///
277/// Construct this struct directly and set the fields you need.
278#[derive(Debug, Clone)]
279pub struct CodeGenConfig {
280    /// Fully-qualified path to the request context type used in handler methods.
281    ///
282    /// No library default; `trestle generate` uses `"crate::api::RequestContext"` unless
283    /// `generate.server.context_type` is set.
284    pub context_type_path: String,
285
286    /// Fully-qualified path to the `Result` alias used in generated handler and client code.
287    /// The clients use the `Result` of the generated error module instead when
288    /// [`RestClientOptions::error_module`] is set.
289    ///
290    /// No library default; `trestle generate` uses `"crate::Result"` unless
291    /// `generate.server.result_type` is set.
292    pub result_type_path: String,
293
294    /// Template for the external model import path. `{service}` is replaced with the service's
295    /// base path (e.g. `"catalogs"`).
296    ///
297    /// Example: `"my_common::models::{service}::v1"`
298    pub models_path_template: String,
299
300    /// Template for crate-local model import path. `{service}` is replaced with the service's
301    /// base path.
302    ///
303    /// Default: `"crate::models::{service}::v1"`
304    pub models_path_crate_template: String,
305
306    /// Output directory configuration.
307    pub output: CodeGenOutput,
308
309    /// When `true`, generate `labels.rs` with `Resource` / `ObjectLabel` enums derived
310    /// from `google.api.resource` annotations. Requires `output.models` to be `Some`.
311    ///
312    /// Store-specific output (`Label` impl, `RESOURCE_DESCRIPTORS`) is only emitted when
313    /// `generate_store_integration` is also `true`.
314    pub generate_resource_enum: bool,
315
316    /// When `true` (and `generate_resource_enum` is set), emit the `olai_store` integration
317    /// code in `labels.rs`:
318    /// - `impl olai_store::Label for ObjectLabel`
319    /// - `pub static RESOURCE_DESCRIPTORS: &[olai_store::ResourceTypeDescriptor<ObjectLabel>]`
320    ///
321    /// Set to `false` for crates that use the enums without a store dependency.
322    pub generate_store_integration: bool,
323
324    /// Fully-qualified path to the `Error` type used in generated `TryFrom<Resource>` impls.
325    ///
326    /// E.g. `"crate::Error"`. When `None`, `TryFrom` impls are not generated.
327    pub error_type_path: Option<String>,
328
329    /// When `true` and `generate_resource_enum` is set, emit `TryFrom<Object>`/`TryFrom<T>`
330    /// and `ResourceExt` impl blocks in `labels.rs` for all resource types that have an
331    /// `IDENTIFIER`-annotated field, plus a `qualified_name()` inherent method on each
332    /// resource type.
333    pub generate_object_conversions: bool,
334
335    /// Configuration for language-binding generation. Required when `output.python`,
336    /// `output.node`, or `output.node_ts` is `Some`.
337    pub bindings: Option<BindingsConfig>,
338
339    /// Crate name for the resource store types used in generated `RESOURCE_DESCRIPTORS`.
340    ///
341    /// Default: `"olai_store"`
342    pub resource_store_crate_name: String,
343
344    /// Protobuf runtime the generated code is shaped for. See [`Runtime`].
345    ///
346    /// Defaults to [`Runtime::Prost`] for backward compatibility.
347    pub runtime: Runtime,
348
349    /// Fully-qualified path to the HTTP transport type that generated clients store and call.
350    ///
351    /// The transport must expose the surface generated client bodies use: per-verb builder
352    /// methods (`get`/`post`/`patch`/`delete`/`put`) returning a request builder with
353    /// `.json(..)`/`.query(..)`/`.send()`, whose response has `.status()`/`.bytes()`.
354    /// [`olai_http::CloudClient`](https://docs.rs/olai-http) satisfies this.
355    ///
356    /// Defaults to `"olai_http::CloudClient"`, so native output is unchanged. A WASM/browser
357    /// client points this at a lightweight reqwest-Fetch transport that has no `ring`/`tokio`
358    /// dependency and lets the browser attach the session.
359    pub transport_type_path: String,
360
361    /// Force dual-transport client emission (the `cfg`-gated `CloudClient`/`WasmClient`
362    /// `Transport` alias) even when no WASM `#[wasm_bindgen]` bindings output is
363    /// configured. See [`dual_transport`](Self::dual_transport).
364    ///
365    /// Defaults to `false`. Setting `output.wasm` also turns dual-transport on;
366    /// this flag exists so a project can build the same client crate for native
367    /// and `wasm32` *without* pulling in the JS binding layer.
368    pub dual_transport: bool,
369
370    /// Which client protocol layer(s) to emit. See [`ClientProtocols`].
371    ///
372    /// Defaults to REST only, so existing HTTP/JSON output is unchanged.
373    pub client_protocols: ClientProtocols,
374
375    /// Import path of the **connect-rust** (BSR plugin) generated client module, used only when
376    /// [`client_protocols`](Self::client_protocols) includes Connect.
377    ///
378    /// The generated Connect builder layer references the connect-rust client type as
379    /// `<connect_client_path>::{Service}Client`. The path should resolve to the module that
380    /// holds the per-service `{Service}Client<T>` structs (e.g.
381    /// `"headwaters_proto::connect_gen::headwaters::read::v1"`).
382    ///
383    /// Required (non-`None`) whenever Connect is selected and client output is enabled; ignored for
384    /// REST-only output.
385    pub connect_client_path: Option<String>,
386
387    /// Wire-format and error options of the REST client. See [`RestClientOptions`].
388    pub rest_client: RestClientOptions,
389}
390
391/// Wire-format and error options of the generated REST client. Every option is off by default.
392#[derive(Debug, Clone, Default)]
393pub struct RestClientOptions {
394    /// Write 64-bit integers as JSON numbers instead of proto3-JSON strings, and whole numbers
395    /// of `google.protobuf.Value`/`Struct`/`ListValue` as integers (`1`, not `1.0`). Emits a
396    /// `json_int64` module into the models `mod.rs` that the `trestle` CLI points buffa's helpers
397    /// at (see [`CodeGenConfig::json_int64_module_path`]). Reading accepts both forms. Buffa only.
398    pub json_int64_as_number: bool,
399
400    /// Parse responses with buffa's `ignore_unknown_enum_values`: an unknown enum name becomes
401    /// the zero value instead of failing the response. Buffa only.
402    pub ignore_unknown_enum_values: bool,
403
404    /// Generate the client error module (`error.rs`); the clients then return its `Result`
405    /// instead of [`CodeGenConfig::result_type_path`]. When `None`, the crate supplies
406    /// `crate::error::parse_error_response`.
407    pub error_module: Option<ErrorModuleConfig>,
408
409    /// RPCs (`package.Service.Method`) whose request body is sent form-encoded.
410    pub form_encoded_methods: Vec<String>,
411
412    /// RPCs (`package.Service.Method`) whose request body keeps the path fields, for APIs that
413    /// want them in both places.
414    pub path_fields_in_body_methods: Vec<String>,
415
416    /// Proto packages whose query keys are the JSON (lowerCamelCase) names. Elsewhere a query
417    /// key is the proto name, or the field's explicit `json_name`.
418    pub json_query_packages: Vec<String>,
419
420    /// The `User-Agent` product of the generated `reqwest` transport
421    /// (`<product>/<client crate version>`); not sent in the browser. Needs that transport.
422    pub user_agent: Option<String>,
423
424    /// Gate each service behind a cargo feature named after its model package
425    /// (`my.catalogs.v1` → `catalogs`): the service modules, the aggregate client's accessors
426    /// and imports, and the child navigators of resource clients (on the child's feature). The
427    /// client crate declares the features.
428    pub resource_features: bool,
429
430    /// Implement the server handler traits for the per-service clients. See
431    /// [`ClientHandlerImpls`].
432    pub handler_impls: Option<ClientHandlerImpls>,
433}
434
435/// `impl <Handler> for <ServiceClient>` per service, forwarding each trait method to the client
436/// method of the same name, so a client serves its service from an upstream server. The impls
437/// go in `<service>/handler.rs`, gated on [`feature`](Self::feature) and left out on `wasm32`
438/// (the client futures are not `Send` there). The client error must convert into the handler
439/// error (`impl From<client::Error> for handlers::Error`). The request context is ignored.
440#[derive(Debug, Clone, PartialEq, Eq)]
441pub struct ClientHandlerImpls {
442    /// The crate with the handler traits (e.g. `my_handlers`). A service's trait is
443    /// `<crate_path>::<service base path>::<Handler>`, the result type `<crate_path>::Result`.
444    pub crate_path: String,
445    /// The client crate feature that enables the impls.
446    pub feature: String,
447    /// Services (by base path, e.g. `temporary_credentials`) whose impl the crate writes by hand.
448    pub skip: Vec<String>,
449}
450
451/// Shape of the JSON error body that the generated error module reads, such as
452/// `{"error_code": "NOT_FOUND", "message": "…", "details": […]}`. A body that is not such an
453/// object becomes the message.
454#[derive(Debug, Clone)]
455pub struct ErrorModuleConfig {
456    /// Key of the error code; its lowerCamelCase form is also read. Default `error_code`.
457    pub code_field: String,
458    /// Key of the message. Default `message`.
459    pub message_field: String,
460    /// Key of the details array. Default `details`.
461    pub details_field: Option<String>,
462    /// Fully-qualified proto enum (e.g. `.my.errors.v1.ErrorCode`) of the error codes. The
463    /// error exposes the code as that enum; an unknown code stays a string.
464    pub code_enum: Option<String>,
465    /// More keys of the code, tried in order (e.g. OAuth's `error`).
466    pub more_code_fields: Vec<String>,
467    /// More keys of the message, tried in order (e.g. OAuth's `error_description`).
468    pub more_message_fields: Vec<String>,
469    /// Keys under which some answers nest the error object (`{"error": {"message": …}}`).
470    pub envelope_fields: Vec<String>,
471}
472
473impl Default for ErrorModuleConfig {
474    fn default() -> Self {
475        Self {
476            code_field: "error_code".into(),
477            message_field: "message".into(),
478            details_field: Some("details".into()),
479            code_enum: None,
480            more_code_fields: Vec::new(),
481            more_message_fields: Vec::new(),
482            envelope_fields: Vec::new(),
483        }
484    }
485}
486
487/// Default transport type path: the cloud client used by native (non-WASM) generated code.
488pub const DEFAULT_TRANSPORT_TYPE_PATH: &str = "olai_http::CloudClient";
489
490impl CodeGenConfig {
491    /// Whether generated clients use the default cloud transport (`olai_http::CloudClient`).
492    ///
493    /// Controls emission of cloud-specific aggregate constructors (`new_unauthenticated`,
494    /// `new_with_token`), which only make sense for `CloudClient`. A custom transport (e.g. the
495    /// WASM/browser one) gets only the generic `new(transport, base_url)`.
496    pub fn uses_default_transport(&self) -> bool {
497        self.transport_type_path == DEFAULT_TRANSPORT_TYPE_PATH
498    }
499
500    /// Whether generated clients use the generated `reqwest` transport
501    /// ([`REQWEST_TRANSPORT_TYPE_PATH`](super::REQWEST_TRANSPORT_TYPE_PATH)).
502    pub fn uses_reqwest_transport(&self) -> bool {
503        !self.dual_transport() && self.transport_type_path == super::REQWEST_TRANSPORT_TYPE_PATH
504    }
505
506    /// Whether the generated client also builds for `wasm32`, where the transport's futures are
507    /// not `Send`, so the builders box them as `Local*` there.
508    pub fn builds_for_wasm(&self) -> bool {
509        self.dual_transport() || self.uses_reqwest_transport()
510    }
511
512    /// Whether the generated client should carry **both** transports, selected at
513    /// compile time by `cfg(target_arch = "wasm32")` — `CloudClient` for native
514    /// targets, `olai_http_wasm::WasmClient` for the browser.
515    ///
516    /// Enabled when WASM bindings output is requested (`output.wasm`), or when
517    /// [`dual_transport`](Self::dual_transport) is set explicitly: a project that
518    /// wants a browser client needs the same client crate to build both ways
519    /// (native for server-side/tests, wasm32 for the frontend). Native-only
520    /// projects leave both unset and keep the single
521    /// [`transport_type_path`](Self::transport_type_path) transport with no WASM
522    /// dependency.
523    ///
524    /// The explicit flag decouples the transport alias from the JS binding layer:
525    /// `output.wasm` additionally emits `#[wasm_bindgen]` bindings, whereas
526    /// `dual_transport = true` alone emits only the `cfg`-gated `Transport` alias.
527    pub fn dual_transport(&self) -> bool {
528        self.output.wasm.is_some() || self.dual_transport
529    }
530
531    /// The crate-local path of the `json_int64` module (e.g. `crate::models::json_int64`), for
532    /// [`ModelRewrites::int64_module`](crate::postprocess::ModelRewrites::int64_module). `None`
533    /// unless [`RestClientOptions::json_int64_as_number`] is set.
534    pub fn json_int64_module_path(&self) -> Option<String> {
535        self.json_number_module_path("json_int64")
536    }
537
538    /// The crate-local path of the `json_value` module (e.g. `crate::models::json_value`), for
539    /// [`ModelRewrites::json_value_module`](crate::postprocess::ModelRewrites::json_value_module).
540    /// `None` unless [`RestClientOptions::json_int64_as_number`] is set.
541    pub fn json_value_module_path(&self) -> Option<String> {
542        self.json_number_module_path("json_value")
543    }
544
545    /// `<models root>::<module>`, unless [`RestClientOptions::json_int64_as_number`] is off.
546    fn json_number_module_path(&self, module: &str) -> Option<String> {
547        if !self.rest_client.json_int64_as_number {
548            return None;
549        }
550        let models_path = ModelsPath::from_template(&self.models_path_crate_template);
551        Some(format!("{}::{module}", models_path.root()?))
552    }
553
554    /// The `Result` type of the generated clients: the generated error module's with
555    /// [`RestClientOptions::error_module`], else [`result_type_path`](Self::result_type_path).
556    pub(crate) fn client_result_path(&self) -> syn::Path {
557        let path = match self.rest_client.error_module {
558            Some(_) => format!("{}::Result", super::error_module::CLIENT_ERROR_MODULE),
559            None => self.result_type_path.clone(),
560        };
561        syn::parse_str(&path).expect("valid result_type_path")
562    }
563
564    /// The module of `parse_error_response`: the generated error module with
565    /// [`RestClientOptions::error_module`], else `crate::error`.
566    pub(crate) fn client_error_module(&self) -> syn::Path {
567        let path = match self.rest_client.error_module {
568            Some(_) => super::error_module::CLIENT_ERROR_MODULE,
569            None => "crate::error",
570        };
571        syn::parse_str(path).expect("valid module path")
572    }
573
574    /// The Python options of the bindings config. Only for the `python` output, which
575    /// [`validate_options`](Self::validate_options) checks has them.
576    pub(crate) fn python_options(&self) -> &PythonOptions {
577        self.bindings_python()
578            .expect("validated: the python output has `BindingsConfig::python`")
579    }
580
581    /// The bindings config. Only for the language-binding outputs, which [`validate`](Self::validate)
582    /// checks have it.
583    pub(crate) fn bindings_config(&self) -> &BindingsConfig {
584        self.bindings
585            .as_ref()
586            .expect("validated: language-binding output has a bindings config")
587    }
588
589    fn bindings_python(&self) -> Option<&PythonOptions> {
590        self.bindings.as_ref()?.python.as_ref()
591    }
592
593    /// Check the options that need other options.
594    pub(crate) fn validate_options(&self) -> Result<()> {
595        let needs = |what: &str, requirement: &str| {
596            Err(Error::Build(format!("`{what}` needs {requirement}")))
597        };
598        if self.output.server.is_some() && self.output.common.is_none() {
599            return needs(
600                "CodeGenOutput::server",
601                "`CodeGenOutput::common` (the Axum extractors)",
602            );
603        }
604        if self.rest_client.user_agent.is_some() && !self.uses_reqwest_transport() {
605            return needs(
606                "RestClientOptions::user_agent",
607                "the generated `reqwest` transport",
608            );
609        }
610        if self.output.python.is_some() {
611            let Some(python) = self.bindings_python() else {
612                return needs("CodeGenOutput::python", "`BindingsConfig::python`");
613            };
614            for path in &python.exports {
615                syn::parse_str::<syn::Path>(path).map_err(|source| Error::InvalidRustPath {
616                    path: path.clone(),
617                    message: "PythonOptions::exports".to_string(),
618                    source,
619                })?;
620            }
621            if python.user_agent.is_some() && !self.uses_reqwest_transport() {
622                return needs(
623                    "PythonOptions::user_agent",
624                    "the generated `reqwest` transport",
625                );
626            }
627        }
628        if self.rest_client.json_int64_as_number
629            && ModelsPath::from_template(&self.models_path_crate_template)
630                .root()
631                .is_none()
632        {
633            return needs(
634                "RestClientOptions::json_int64_as_number",
635                "a `models_path_crate_template` of the form `<models root>::{service}::...`",
636            );
637        }
638        Ok(())
639    }
640
641    /// Validate this config without running code generation.
642    ///
643    /// Checks that:
644    /// - `models_path_template` and `models_path_crate_template` produce valid Rust paths after
645    ///   `{service}` substitution.
646    /// - `context_type_path` and `result_type_path` parse as Rust paths.
647    /// - `bindings` is `Some` whenever `output.python`, `output.node`, `output.node_ts`, or
648    ///   `output.wasm` is `Some`; `output.python` also needs `BindingsConfig::python`.
649    /// - language bindings have the REST client protocol enabled.
650    /// - the Connect client has a `connect_client_path`.
651    /// - the REST client and Python options that need other options have them.
652    ///
653    /// [`generate_code`](crate::generate_code) runs the same checks, so calling this first only
654    /// moves the error to construction time.
655    pub fn validate(&self) -> Result<()> {
656        ModelsPath::new(&self.models_path_template)?;
657        ModelsPath::new(&self.models_path_crate_template)?;
658        // The context/result type paths are parsed as `syn::Path` deep in the per-method
659        // generators; check them once here so a typo is a clean error, not a panic.
660        for (field, path) in [
661            ("context_type", &self.context_type_path),
662            ("result_type", &self.result_type_path),
663        ] {
664            syn::parse_str::<syn::Path>(path).map_err(|source| Error::InvalidConfigPath {
665                field,
666                path: path.clone(),
667                source,
668            })?;
669        }
670        let wants_bindings = self.output.python.is_some()
671            || self.output.node.is_some()
672            || self.output.node_ts.is_some()
673            || self.output.wasm.is_some();
674        if wants_bindings && self.bindings.is_none() {
675            return Err(Error::MissingBindingsConfig);
676        }
677        // The Connect client layers over the connect-rust generated client, whose import path the
678        // emitter needs. Only required when client output is actually requested.
679        if self.client_protocols.connect
680            && self.output.client.is_some()
681            && self.connect_client_path.is_none()
682        {
683            return Err(Error::MissingConnectClientPath);
684        }
685        // Language bindings call into the REST aggregate client, so they need REST enabled.
686        if wants_bindings && !self.client_protocols.rest {
687            return Err(Error::BindingsRequireRest);
688        }
689        self.validate_options()
690    }
691}
692
693/// Output directory configuration for code generation.
694///
695/// All outputs are optional — set to `None` to skip that output entirely. For example, a
696/// server-only crate can omit `client`, and a client-only crate can omit `server` and
697/// `common`.
698#[derive(Debug, Clone)]
699pub struct CodeGenOutput {
700    /// Output directory for the Axum request extractors. Generation is skipped when `None`; the
701    /// `server` output needs it.
702    pub common: Option<PathBuf>,
703    /// Parent models directory (e.g. `crates/common/src/models`).
704    ///
705    /// When `Some`, the generator writes `labels.rs`, `pyo3_impls.rs`, and `mod.rs`
706    /// into a subdirectory named [`models_subdir`](CodeGenOutput::models_subdir)
707    /// inside this path. The buf proto plugin co-locates the compiled model files
708    /// (`<pkg>.rs` / `.serde.rs` / `.tonic.rs`) in that same subdirectory, so the
709    /// `mod.rs` includes them with `include!("./<pkg>.rs")`.
710    pub models: Option<PathBuf>,
711    /// Name of the generated subdirectory inside [`models`](CodeGenOutput::models).
712    ///
713    /// Defaults to `"_gen"`.
714    pub models_subdir: String,
715    /// Output directory for server-side handler and route code. Generation is skipped when `None`.
716    pub server: Option<PathBuf>,
717    /// Output directory for HTTP client code. Generation is skipped when `None`.
718    pub client: Option<PathBuf>,
719    /// Whether to also emit ergonomic resource-scoped clients (`<service>/resource.rs`) alongside
720    /// the low-level per-service client and builders.
721    ///
722    /// A scoped client (e.g. `CatalogClient` bound to a name) captures the resource's name
723    /// components and exposes `get`/`update`/`delete` (+ resource-targeted custom RPCs) returning
724    /// the matching builder. Only emitted for resource-scoped services, and only when
725    /// [`client`](CodeGenOutput::client) is `Some`. Defaults to `false`.
726    pub generate_resource_clients: bool,
727    /// Output directory for Python bindings. Generation is skipped when `None`.
728    pub python: Option<PathBuf>,
729    /// Output directory for Node.js NAPI bindings. Generation is skipped when `None`.
730    pub node: Option<PathBuf>,
731    /// Output directory for Node.js TypeScript client. Generation is skipped when `None`.
732    pub node_ts: Option<PathBuf>,
733    /// Output directory for WASM/browser `#[wasm_bindgen]` bindings + `.d.ts`. Generation is
734    /// skipped when `None`.
735    ///
736    /// Emits a `#[wasm_bindgen]` wrapper layer over the generated (WASM-transport) clients plus a
737    /// `client.d.ts` for JS/TS consumers. Request/response values cross the boundary as plain JS
738    /// objects via `serde-wasm-bindgen`, so this pairs with `runtime: Buffa` (serde-native models)
739    /// and `transport_type_path = "olai_http_wasm::WasmClient"`.
740    pub wasm: Option<PathBuf>,
741}
742
743impl CodeGenOutput {
744    /// Absolute path of the generated subdirectory (`models/models_subdir`), if `models` is set.
745    pub fn models_subdir_path(&self) -> Option<PathBuf> {
746        self.models.as_ref().map(|m| m.join(&self.models_subdir))
747    }
748}