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 ([`super::client`]) 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/// Which Python client flavors the PyO3 bindings expose.
112///
113/// Both flavors wrap the same Rust client and run on one shared Tokio runtime (see the
114/// generated `codegen/runtime.rs`).
115///
116/// - [`PythonApi::Sync`]: blocking clients only (`ExampleClient`). The generated code depends on
117///   `tokio` but not on `pyo3-async-runtimes`.
118/// - [`PythonApi::Async`]: awaitable clients only (`AsyncExampleClient`), built on
119///   `pyo3_async_runtimes::tokio::future_into_py`.
120/// - [`PythonApi::Both`] (default): both sets of classes side by side.
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
122pub enum PythonApi {
123    /// Blocking clients only.
124    Sync,
125    /// Awaitable (`asyncio`) clients only.
126    Async,
127    /// Blocking and awaitable clients.
128    #[default]
129    Both,
130}
131
132impl PythonApi {
133    /// Whether the blocking clients are emitted.
134    pub fn has_sync(self) -> bool {
135        matches!(self, Self::Sync | Self::Both)
136    }
137
138    /// Whether the awaitable clients are emitted (and `pyo3-async-runtimes` is required).
139    pub fn has_async(self) -> bool {
140        matches!(self, Self::Async | Self::Both)
141    }
142}
143
144/// Validated model import path derived from a `{service}` template string.
145///
146/// Constructed once from [`CodeGenConfig`] template fields. `resolve` performs the
147/// `{service}` substitution and parses the result as a [`syn::Path`], catching
148/// malformed templates at construction time rather than at code-generation time.
149#[derive(Debug, Clone)]
150pub struct ModelsPath {
151    template: String,
152}
153
154impl ModelsPath {
155    /// Build a `ModelsPath` from a template string containing `{service}` and,
156    /// optionally, `{version}`.
157    ///
158    /// Performs a test substitution at construction to validate the template.
159    pub fn new(template: &str) -> Result<Self> {
160        let test = template
161            .replace("{service}", "test")
162            .replace("{version}", "v1");
163        syn::parse_str::<syn::Path>(&test).map_err(|e| Error::InvalidModelsPathTemplate {
164            template: template.to_string(),
165            source: e,
166        })?;
167        Ok(Self::from_template(template))
168    }
169
170    /// Wrap a template without the construction-time validation done by [`ModelsPath::new`].
171    ///
172    /// For callers that already validated the template (e.g. `generate_code` validates both
173    /// templates up front, so per-service handlers needn't re-parse on every `models_path()`
174    /// call). `resolve`/`try_resolve` still parse the final substituted path.
175    pub(crate) fn from_template(template: &str) -> Self {
176        Self {
177            template: template.to_string(),
178        }
179    }
180
181    /// Replace `{service}` with `service` and return the parsed [`syn::Path`], erroring if the
182    /// substitution does not parse.
183    ///
184    /// `new` only proves the template parses for the literal `test`; a real service segment that
185    /// isn't a valid path component (leading digit, hyphen, reserved word) can still fail here.
186    /// Call this once per service up front (see [`ModelsPath::validate_for`]) so failures surface
187    /// as a clean [`Error::InvalidModelsPathTemplate`] before generation rather than panicking.
188    pub fn try_resolve(&self, service: &str, version: &str) -> Result<syn::Path> {
189        let path = self.substitute(service, version);
190        syn::parse_str(&path).map_err(|source| Error::InvalidModelsPathTemplate {
191            template: path,
192            source,
193        })
194    }
195
196    /// Validate that `service` substitutes into a parseable path. Used up front, per service.
197    ///
198    /// Validates against the conventional `v1` version; the actual version segment is a plain
199    /// path component derived from the proto package, so it cannot make a `{service}`-valid
200    /// template fail to parse.
201    pub fn validate_for(&self, service: &str) -> Result<()> {
202        self.try_resolve(service, "v1").map(|_| ())
203    }
204
205    /// Replace `{service}`/`{version}` and return the parsed [`syn::Path`].
206    ///
207    /// # Panics
208    ///
209    /// Panics if the substituted path doesn't parse. Generation validates every service segment
210    /// up front via [`ModelsPath::validate_for`], so this is unreachable on the generation path;
211    /// prefer [`ModelsPath::try_resolve`] anywhere that hasn't already validated.
212    pub fn resolve(&self, service: &str, version: &str) -> syn::Path {
213        let path = self.substitute(service, version);
214        syn::parse_str(&path)
215            .unwrap_or_else(|e| panic!("Invalid models path `{path}` after substitution: {e}"))
216    }
217
218    /// Substitute the `{service}` and `{version}` placeholders. A template without `{version}`
219    /// is left as-is for that placeholder, preserving any literal version it already encodes.
220    fn substitute(&self, service: &str, version: &str) -> String {
221        self.template
222            .replace("{service}", service)
223            .replace("{version}", version)
224    }
225}
226
227/// Configuration for language-binding code generation (Python / Node.js).
228#[derive(Debug, Clone)]
229pub struct BindingsConfig {
230    /// Name of the aggregate client struct (e.g. `"MyServiceClient"`).
231    pub aggregate_client_name: String,
232    /// Rust crate name used in `use` statements for the client crate
233    /// (e.g. `"my_service_client"`).
234    ///
235    /// This must be set explicitly because the crate name may not match the
236    /// snake_case form of `aggregate_client_name` (e.g. `"MyServiceClient"`
237    /// snake_cases to `"my_service_client"`).
238    pub client_crate_name: String,
239    /// Fully-qualified Python error type (e.g. `"PyMyServiceError"`).
240    pub py_error_type: String,
241    /// Fully-qualified Python result alias (e.g. `"PyMyServiceResult"`).
242    ///
243    /// Not referenced by the generated PyO3 client, whose methods return `PyResult` (so they can
244    /// surface Python signals such as `KeyboardInterrupt` alongside `py_error_type` errors).
245    /// Retained for configuration compatibility.
246    pub py_result_type: String,
247    /// Which Python client flavors (sync / async / both) to emit. See [`PythonApi`].
248    pub python_api: PythonApi,
249    /// Name of the NAPI error extension trait (e.g. `"NapiErrorExt"`).
250    pub napi_error_ext_trait: String,
251    /// Optional substring filter for the Python typings package.
252    ///
253    /// When `Some(s)`, only messages/enums whose fully-qualified name contains
254    /// `s` are included in the generated `.pyi` file.  When `None`, all
255    /// reachable types are included.
256    pub typings_package_filter: Option<String>,
257    /// Base class name for TypeScript errors (e.g. `"MyServiceError"`).
258    pub ts_error_base_class: String,
259    /// Prefix used in native NAPI error messages (e.g. `"UC"`).
260    pub ts_error_code_prefix: String,
261}
262
263/// Configuration for code generation, including import paths and output directories.
264///
265/// Construct this struct directly and set the fields you need.
266#[derive(Debug, Clone)]
267pub struct CodeGenConfig {
268    /// Fully-qualified path to the request context type used in handler methods.
269    ///
270    /// Default: `"crate::api::RequestContext"`
271    pub context_type_path: String,
272
273    /// Fully-qualified path to the `Result` alias used in generated handler and client code.
274    ///
275    /// Default: `"crate::Result"`
276    pub result_type_path: String,
277
278    /// Template for the external model import path. `{service}` is replaced with the service's
279    /// base path (e.g. `"catalogs"`).
280    ///
281    /// Example: `"my_common::models::{service}::v1"`
282    pub models_path_template: String,
283
284    /// Template for crate-local model import path. `{service}` is replaced with the service's
285    /// base path.
286    ///
287    /// Default: `"crate::models::{service}::v1"`
288    pub models_path_crate_template: String,
289
290    /// Output directory configuration.
291    pub output: CodeGenOutput,
292
293    /// When `true`, generate `labels.rs` with `Resource` / `ObjectLabel` enums derived
294    /// from `google.api.resource` annotations. Requires `output.models` to be `Some`.
295    ///
296    /// Store-specific output (`Label` impl, `RESOURCE_DESCRIPTORS`) is only emitted when
297    /// `generate_store_integration` is also `true`.
298    pub generate_resource_enum: bool,
299
300    /// When `true` (and `generate_resource_enum` is set), emit the `olai_store` integration
301    /// code in `labels.rs`:
302    /// - `impl olai_store::Label for ObjectLabel`
303    /// - `pub static RESOURCE_DESCRIPTORS: &[olai_store::ResourceTypeDescriptor<ObjectLabel>]`
304    ///
305    /// Set to `false` for crates that use the enums without a store dependency.
306    pub generate_store_integration: bool,
307
308    /// Fully-qualified path to the `Error` type used in generated `TryFrom<Resource>` impls.
309    ///
310    /// E.g. `"crate::Error"`. When `None`, `TryFrom` impls are not generated.
311    pub error_type_path: Option<String>,
312
313    /// When `true` and `generate_resource_enum` is set, emit `TryFrom<Object>`/`TryFrom<T>`
314    /// and `ResourceExt` impl blocks in `labels.rs` for all resource types that have an
315    /// `IDENTIFIER`-annotated field, plus a `qualified_name()` inherent method on each
316    /// resource type.
317    pub generate_object_conversions: bool,
318
319    /// Configuration for language-binding generation. Required when `output.python`,
320    /// `output.node`, or `output.node_ts` is `Some`.
321    pub bindings: Option<BindingsConfig>,
322
323    /// Crate name for the resource store types used in generated `RESOURCE_DESCRIPTORS`.
324    ///
325    /// Default: `"olai_store"`
326    pub resource_store_crate_name: String,
327
328    /// Protobuf runtime the generated code is shaped for. See [`Runtime`].
329    ///
330    /// Defaults to [`Runtime::Prost`] for backward compatibility.
331    pub runtime: Runtime,
332
333    /// Fully-qualified path to the HTTP transport type that generated clients store and call.
334    ///
335    /// The transport must expose the surface generated client bodies use: per-verb builder
336    /// methods (`get`/`post`/`patch`/`delete`/`put`) returning a request builder with
337    /// `.json(..)`/`.query(..)`/`.send()`, whose response has `.status()`/`.bytes()`.
338    /// [`olai_http::CloudClient`](https://docs.rs/olai-http) satisfies this.
339    ///
340    /// Defaults to `"olai_http::CloudClient"`, so native output is unchanged. A WASM/browser
341    /// client points this at a lightweight reqwest-Fetch transport that has no `ring`/`tokio`
342    /// dependency and lets the browser attach the session.
343    pub transport_type_path: String,
344
345    /// Force dual-transport client emission (the `cfg`-gated `CloudClient`/`WasmClient`
346    /// `Transport` alias) even when no WASM `#[wasm_bindgen]` bindings output is
347    /// configured. See [`dual_transport`](Self::dual_transport).
348    ///
349    /// Defaults to `false`. Setting `output.wasm` also turns dual-transport on;
350    /// this flag exists so a project can build the same client crate for native
351    /// and `wasm32` *without* pulling in the JS binding layer.
352    pub dual_transport: bool,
353
354    /// Which client protocol layer(s) to emit. See [`ClientProtocols`].
355    ///
356    /// Defaults to REST only, so existing HTTP/JSON output is unchanged.
357    pub client_protocols: ClientProtocols,
358
359    /// Import path of the **connect-rust** (BSR plugin) generated client module, used only when
360    /// [`client_protocols`](Self::client_protocols) includes Connect.
361    ///
362    /// The generated Connect builder layer references the connect-rust client type as
363    /// `<connect_client_path>::{Service}Client`. The path should resolve to the module that
364    /// holds the per-service `{Service}Client<T>` structs (e.g.
365    /// `"headwaters_proto::connect_gen::headwaters::read::v1"`).
366    ///
367    /// Required (non-`None`) whenever Connect is selected and client output is enabled; ignored for
368    /// REST-only output.
369    pub connect_client_path: Option<String>,
370}
371
372/// Default transport type path: the cloud client used by native (non-WASM) generated code.
373pub const DEFAULT_TRANSPORT_TYPE_PATH: &str = "olai_http::CloudClient";
374
375impl CodeGenConfig {
376    /// Whether generated clients use the default cloud transport (`olai_http::CloudClient`).
377    ///
378    /// Controls emission of cloud-specific aggregate constructors (`new_unauthenticated`,
379    /// `new_with_token`), which only make sense for `CloudClient`. A custom transport (e.g. the
380    /// WASM/browser one) gets only the generic `new(transport, base_url)`.
381    pub fn uses_default_transport(&self) -> bool {
382        self.transport_type_path == DEFAULT_TRANSPORT_TYPE_PATH
383    }
384
385    /// Whether the generated client should carry **both** transports, selected at
386    /// compile time by `cfg(target_arch = "wasm32")` — `CloudClient` for native
387    /// targets, `olai_http_wasm::WasmClient` for the browser.
388    ///
389    /// Enabled when WASM bindings output is requested (`output.wasm`), or when
390    /// [`dual_transport`](Self::dual_transport) is set explicitly: a project that
391    /// wants a browser client needs the same client crate to build both ways
392    /// (native for server-side/tests, wasm32 for the frontend). Native-only
393    /// projects leave both unset and keep the single
394    /// [`transport_type_path`](Self::transport_type_path) transport with no WASM
395    /// dependency.
396    ///
397    /// The explicit flag decouples the transport alias from the JS binding layer:
398    /// `output.wasm` additionally emits `#[wasm_bindgen]` bindings, whereas
399    /// `dual_transport = true` alone emits only the `cfg`-gated `Transport` alias.
400    pub fn dual_transport(&self) -> bool {
401        self.output.wasm.is_some() || self.dual_transport
402    }
403
404    /// Validate this config without running code generation.
405    ///
406    /// Checks that:
407    /// - `models_path_template` and `models_path_crate_template` produce valid Rust paths after
408    ///   `{service}` substitution.
409    /// - `bindings` is `Some` whenever `output.python`, `output.node`, `output.node_ts`, or
410    ///   `output.wasm` is `Some`.
411    ///
412    /// Call this at construction time to surface misconfiguration early, before generation runs.
413    pub fn validate(&self) -> Result<()> {
414        ModelsPath::new(&self.models_path_template)?;
415        ModelsPath::new(&self.models_path_crate_template)?;
416        let wants_bindings = self.output.python.is_some()
417            || self.output.node.is_some()
418            || self.output.node_ts.is_some()
419            || self.output.wasm.is_some();
420        if wants_bindings && self.bindings.is_none() {
421            return Err(Error::MissingBindingsConfig);
422        }
423        // The Connect client layers over the connect-rust generated client, whose import path the
424        // emitter needs. Only required when client output is actually requested.
425        if self.client_protocols.connect
426            && self.output.client.is_some()
427            && self.connect_client_path.is_none()
428        {
429            return Err(Error::MissingConnectClientPath);
430        }
431        // Language bindings call into the REST aggregate client, so they need REST enabled.
432        if wants_bindings && !self.client_protocols.rest {
433            return Err(Error::BindingsRequireRest);
434        }
435        Ok(())
436    }
437}
438
439/// Output directory configuration for code generation.
440///
441/// Only `common` is required. All other outputs are optional — set to `None` to skip that
442/// output entirely. For example, a server-only crate can omit `client`, and a client-only
443/// crate can omit `server`.
444#[derive(Debug, Clone)]
445pub struct CodeGenOutput {
446    /// Output directory for common (shared extractor) code.
447    pub common: PathBuf,
448    /// Parent models directory (e.g. `crates/common/src/models`).
449    ///
450    /// When `Some`, the generator writes `labels.rs`, `pyo3_impls.rs`, and `mod.rs`
451    /// into a subdirectory named [`models_subdir`](CodeGenOutput::models_subdir)
452    /// inside this path. The buf proto plugin co-locates the compiled model files
453    /// (`<pkg>.rs` / `.serde.rs` / `.tonic.rs`) in that same subdirectory, so the
454    /// `mod.rs` includes them with `include!("./<pkg>.rs")`.
455    pub models: Option<PathBuf>,
456    /// Name of the generated subdirectory inside [`models`](CodeGenOutput::models).
457    ///
458    /// Defaults to `"_gen"`.
459    pub models_subdir: String,
460    /// Output directory for server-side handler and route code. Generation is skipped when `None`.
461    pub server: Option<PathBuf>,
462    /// Output directory for HTTP client code. Generation is skipped when `None`.
463    pub client: Option<PathBuf>,
464    /// Whether to also emit ergonomic resource-scoped clients (`<service>/resource.rs`) alongside
465    /// the low-level per-service client and builders.
466    ///
467    /// A scoped client (e.g. `CatalogClient` bound to a name) captures the resource's name
468    /// components and exposes `get`/`update`/`delete` (+ resource-targeted custom RPCs) returning
469    /// the matching builder. Only emitted for resource-scoped services, and only when
470    /// [`client`](CodeGenOutput::client) is `Some`. Defaults to `false`.
471    pub generate_resource_clients: bool,
472    /// Output directory for Python bindings. Generation is skipped when `None`.
473    pub python: Option<PathBuf>,
474    /// Output directory for Node.js NAPI bindings. Generation is skipped when `None`.
475    pub node: Option<PathBuf>,
476    /// Output directory for Node.js TypeScript client. Generation is skipped when `None`.
477    pub node_ts: Option<PathBuf>,
478    /// Output directory for WASM/browser `#[wasm_bindgen]` bindings + `.d.ts`. Generation is
479    /// skipped when `None`.
480    ///
481    /// Emits a `#[wasm_bindgen]` wrapper layer over the generated (WASM-transport) clients plus a
482    /// `client.d.ts` for JS/TS consumers. Request/response values cross the boundary as plain JS
483    /// objects via `serde-wasm-bindgen`, so this pairs with `runtime: Buffa` (serde-native models)
484    /// and `transport_type_path = "olai_http_wasm::WasmClient"`.
485    pub wasm: Option<PathBuf>,
486    /// Filename for the generated Python typings stub.
487    ///
488    /// Default: `"client.pyi"`
489    pub python_typings_filename: String,
490}
491
492impl CodeGenOutput {
493    /// Absolute path of the generated subdirectory (`models/models_subdir`), if `models` is set.
494    pub fn models_subdir_path(&self) -> Option<PathBuf> {
495        self.models.as_ref().map(|m| m.join(&self.models_subdir))
496    }
497}