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/// Protobuf runtime that the *generated* code is expected to consume.
11///
12/// This selects the ABI that emitted clients, handlers, and builders are shaped for —
13/// i.e. how generated code reads and writes the consuming project's model types. It does
14/// **not** affect how `olai-codegen` itself parses descriptors.
15///
16/// - [`Runtime::Prost`] (default): models are [prost](https://docs.rs/prost)-generated —
17///   open enums are bare `i32`, singular message fields are `Option<Box<T>>`.
18/// - [`Runtime::Buffa`]: models are [buffa](https://github.com/anthropics/buffa)-generated —
19///   open enums are `EnumValue<E>`, singular message fields are `MessageField<T>`, and the
20///   runtime provides native serde JSON (no separate `pbjson` layer).
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
22pub enum Runtime {
23    /// prost-generated models (the historical default).
24    #[default]
25    Prost,
26    /// buffa-generated models.
27    Buffa,
28}
29
30/// Validated model import path derived from a `{service}` template string.
31///
32/// Constructed once from [`CodeGenConfig`] template fields. `resolve` performs the
33/// `{service}` substitution and parses the result as a [`syn::Path`], catching
34/// malformed templates at construction time rather than at code-generation time.
35#[derive(Debug, Clone)]
36pub struct ModelsPath {
37    template: String,
38}
39
40impl ModelsPath {
41    /// Build a `ModelsPath` from a template string containing `{service}` and,
42    /// optionally, `{version}`.
43    ///
44    /// Performs a test substitution at construction to validate the template.
45    pub fn new(template: &str) -> Result<Self> {
46        let test = template
47            .replace("{service}", "test")
48            .replace("{version}", "v1");
49        syn::parse_str::<syn::Path>(&test).map_err(|e| Error::InvalidModelsPathTemplate {
50            template: template.to_string(),
51            source: e,
52        })?;
53        Ok(Self::from_template(template))
54    }
55
56    /// Wrap a template without the construction-time validation done by [`ModelsPath::new`].
57    ///
58    /// For callers that already validated the template (e.g. `generate_code` validates both
59    /// templates up front, so per-service handlers needn't re-parse on every `models_path()`
60    /// call). `resolve`/`try_resolve` still parse the final substituted path.
61    pub(crate) fn from_template(template: &str) -> Self {
62        Self {
63            template: template.to_string(),
64        }
65    }
66
67    /// Replace `{service}` with `service` and return the parsed [`syn::Path`], erroring if the
68    /// substitution does not parse.
69    ///
70    /// `new` only proves the template parses for the literal `test`; a real service segment that
71    /// isn't a valid path component (leading digit, hyphen, reserved word) can still fail here.
72    /// Call this once per service up front (see [`ModelsPath::validate_for`]) so failures surface
73    /// as a clean [`Error::InvalidModelsPathTemplate`] before generation rather than panicking.
74    pub fn try_resolve(&self, service: &str, version: &str) -> Result<syn::Path> {
75        let path = self.substitute(service, version);
76        syn::parse_str(&path).map_err(|source| Error::InvalidModelsPathTemplate {
77            template: path,
78            source,
79        })
80    }
81
82    /// Validate that `service` substitutes into a parseable path. Used up front, per service.
83    ///
84    /// Validates against the conventional `v1` version; the actual version segment is a plain
85    /// path component derived from the proto package, so it cannot make a `{service}`-valid
86    /// template fail to parse.
87    pub fn validate_for(&self, service: &str) -> Result<()> {
88        self.try_resolve(service, "v1").map(|_| ())
89    }
90
91    /// Replace `{service}`/`{version}` and return the parsed [`syn::Path`].
92    ///
93    /// # Panics
94    ///
95    /// Panics if the substituted path doesn't parse. Generation validates every service segment
96    /// up front via [`ModelsPath::validate_for`], so this is unreachable on the generation path;
97    /// prefer [`ModelsPath::try_resolve`] anywhere that hasn't already validated.
98    pub fn resolve(&self, service: &str, version: &str) -> syn::Path {
99        let path = self.substitute(service, version);
100        syn::parse_str(&path)
101            .unwrap_or_else(|e| panic!("Invalid models path `{path}` after substitution: {e}"))
102    }
103
104    /// Substitute the `{service}` and `{version}` placeholders. A template without `{version}`
105    /// is left as-is for that placeholder, preserving any literal version it already encodes.
106    fn substitute(&self, service: &str, version: &str) -> String {
107        self.template
108            .replace("{service}", service)
109            .replace("{version}", version)
110    }
111}
112
113/// Configuration for language-binding code generation (Python / Node.js).
114#[derive(Debug, Clone)]
115pub struct BindingsConfig {
116    /// Name of the aggregate client struct (e.g. `"MyServiceClient"`).
117    pub aggregate_client_name: String,
118    /// Rust crate name used in `use` statements for the client crate
119    /// (e.g. `"my_service_client"`).
120    ///
121    /// This must be set explicitly because the crate name may not match the
122    /// snake_case form of `aggregate_client_name` (e.g. `"MyServiceClient"`
123    /// snake_cases to `"my_service_client"`).
124    pub client_crate_name: String,
125    /// Fully-qualified Python error type (e.g. `"PyMyServiceError"`).
126    pub py_error_type: String,
127    /// Fully-qualified Python result alias (e.g. `"PyMyServiceResult"`).
128    pub py_result_type: String,
129    /// Name of the NAPI error extension trait (e.g. `"NapiErrorExt"`).
130    pub napi_error_ext_trait: String,
131    /// Optional substring filter for the Python typings package.
132    ///
133    /// When `Some(s)`, only messages/enums whose fully-qualified name contains
134    /// `s` are included in the generated `.pyi` file.  When `None`, all
135    /// reachable types are included.
136    pub typings_package_filter: Option<String>,
137    /// Base class name for TypeScript errors (e.g. `"MyServiceError"`).
138    pub ts_error_base_class: String,
139    /// Prefix used in native NAPI error messages (e.g. `"UC"`).
140    pub ts_error_code_prefix: String,
141}
142
143/// Configuration for code generation, including import paths and output directories.
144///
145/// Construct this struct directly and set the fields you need.
146#[derive(Debug, Clone)]
147pub struct CodeGenConfig {
148    /// Fully-qualified path to the request context type used in handler methods.
149    ///
150    /// Default: `"crate::api::RequestContext"`
151    pub context_type_path: String,
152
153    /// Fully-qualified path to the `Result` alias used in generated handler and client code.
154    ///
155    /// Default: `"crate::Result"`
156    pub result_type_path: String,
157
158    /// Template for the external model import path. `{service}` is replaced with the service's
159    /// base path (e.g. `"catalogs"`).
160    ///
161    /// Example: `"my_common::models::{service}::v1"`
162    pub models_path_template: String,
163
164    /// Template for crate-local model import path. `{service}` is replaced with the service's
165    /// base path.
166    ///
167    /// Default: `"crate::models::{service}::v1"`
168    pub models_path_crate_template: String,
169
170    /// Output directory configuration.
171    pub output: CodeGenOutput,
172
173    /// When `true`, generate `labels.rs` with `Resource` / `ObjectLabel` enums derived
174    /// from `google.api.resource` annotations. Requires `output.models` to be `Some`.
175    ///
176    /// Store-specific output (`Label` impl, `RESOURCE_DESCRIPTORS`) is only emitted when
177    /// `generate_store_integration` is also `true`.
178    pub generate_resource_enum: bool,
179
180    /// When `true` (and `generate_resource_enum` is set), emit the `olai_store` integration
181    /// code in `labels.rs`:
182    /// - `impl olai_store::Label for ObjectLabel`
183    /// - `pub static RESOURCE_DESCRIPTORS: &[olai_store::ResourceTypeDescriptor<ObjectLabel>]`
184    ///
185    /// Set to `false` for crates that use the enums without a store dependency.
186    pub generate_store_integration: bool,
187
188    /// Fully-qualified path to the `Error` type used in generated `TryFrom<Resource>` impls.
189    ///
190    /// E.g. `"crate::Error"`. When `None`, `TryFrom` impls are not generated.
191    pub error_type_path: Option<String>,
192
193    /// When `true` and `generate_resource_enum` is set, emit `TryFrom<Object>`/`TryFrom<T>`
194    /// and `ResourceExt` impl blocks in `labels.rs` for all resource types that have an
195    /// `IDENTIFIER`-annotated field, plus a `qualified_name()` inherent method on each
196    /// resource type.
197    pub generate_object_conversions: bool,
198
199    /// Configuration for language-binding generation. Required when `output.python`,
200    /// `output.node`, or `output.node_ts` is `Some`.
201    pub bindings: Option<BindingsConfig>,
202
203    /// Crate name for the resource store types used in generated `RESOURCE_DESCRIPTORS`.
204    ///
205    /// Default: `"olai_store"`
206    pub resource_store_crate_name: String,
207
208    /// Protobuf runtime the generated code is shaped for. See [`Runtime`].
209    ///
210    /// Defaults to [`Runtime::Prost`] for backward compatibility.
211    pub runtime: Runtime,
212
213    /// Fully-qualified path to the HTTP transport type that generated clients store and call.
214    ///
215    /// The transport must expose the surface generated client bodies use: per-verb builder
216    /// methods (`get`/`post`/`patch`/`delete`/`put`) returning a request builder with
217    /// `.json(..)`/`.query(..)`/`.send()`, whose response has `.status()`/`.bytes()`.
218    /// [`olai_http::CloudClient`](https://docs.rs/olai-http) satisfies this.
219    ///
220    /// Defaults to `"olai_http::CloudClient"`, so native output is unchanged. A WASM/browser
221    /// client points this at a lightweight reqwest-Fetch transport that has no `ring`/`tokio`
222    /// dependency and lets the browser attach the session.
223    pub transport_type_path: String,
224}
225
226/// Default transport type path: the cloud client used by native (non-WASM) generated code.
227pub const DEFAULT_TRANSPORT_TYPE_PATH: &str = "olai_http::CloudClient";
228
229impl CodeGenConfig {
230    /// Whether generated clients use the default cloud transport (`olai_http::CloudClient`).
231    ///
232    /// Controls emission of cloud-specific aggregate constructors (`new_unauthenticated`,
233    /// `new_with_token`), which only make sense for `CloudClient`. A custom transport (e.g. the
234    /// WASM/browser one) gets only the generic `new(transport, base_url)`.
235    pub fn uses_default_transport(&self) -> bool {
236        self.transport_type_path == DEFAULT_TRANSPORT_TYPE_PATH
237    }
238
239    /// Whether the generated client should carry **both** transports, selected at
240    /// compile time by `cfg(target_arch = "wasm32")` — `CloudClient` for native
241    /// targets, `olai_http_wasm::WasmClient` for the browser.
242    ///
243    /// Enabled when WASM bindings output is requested (`output.wasm`): a project
244    /// that wants a browser client needs the same client crate to build both ways
245    /// (native for server-side/tests, wasm32 for the frontend). Native-only
246    /// projects leave `output.wasm` unset and keep the single
247    /// [`transport_type_path`](Self::transport_type_path) transport with no WASM
248    /// dependency.
249    pub fn dual_transport(&self) -> bool {
250        self.output.wasm.is_some()
251    }
252
253    /// Validate this config without running code generation.
254    ///
255    /// Checks that:
256    /// - `models_path_template` and `models_path_crate_template` produce valid Rust paths after
257    ///   `{service}` substitution.
258    /// - `bindings` is `Some` whenever `output.python`, `output.node`, `output.node_ts`, or
259    ///   `output.wasm` is `Some`.
260    ///
261    /// Call this at construction time to surface misconfiguration early, before generation runs.
262    pub fn validate(&self) -> Result<()> {
263        ModelsPath::new(&self.models_path_template)?;
264        ModelsPath::new(&self.models_path_crate_template)?;
265        if (self.output.python.is_some()
266            || self.output.node.is_some()
267            || self.output.node_ts.is_some()
268            || self.output.wasm.is_some())
269            && self.bindings.is_none()
270        {
271            return Err(Error::MissingBindingsConfig);
272        }
273        Ok(())
274    }
275}
276
277/// Output directory configuration for code generation.
278///
279/// Only `common` is required. All other outputs are optional — set to `None` to skip that
280/// output entirely. For example, a server-only crate can omit `client`, and a client-only
281/// crate can omit `server`.
282#[derive(Debug, Clone)]
283pub struct CodeGenOutput {
284    /// Output directory for common (shared extractor) code.
285    pub common: PathBuf,
286    /// Parent models directory (e.g. `crates/common/src/models`).
287    ///
288    /// When `Some`, the generator writes `labels.rs`, `pyo3_impls.rs`, and `mod.rs`
289    /// into a subdirectory named [`models_subdir`](CodeGenOutput::models_subdir)
290    /// inside this path. The buf proto plugin co-locates the compiled model files
291    /// (`<pkg>.rs` / `.serde.rs` / `.tonic.rs`) in that same subdirectory, so the
292    /// `mod.rs` includes them with `include!("./<pkg>.rs")`.
293    pub models: Option<PathBuf>,
294    /// Name of the generated subdirectory inside [`models`](CodeGenOutput::models).
295    ///
296    /// Defaults to `"_gen"`.
297    pub models_subdir: String,
298    /// Output directory for server-side handler and route code. Generation is skipped when `None`.
299    pub server: Option<PathBuf>,
300    /// Output directory for HTTP client code. Generation is skipped when `None`.
301    pub client: Option<PathBuf>,
302    /// Whether to also emit ergonomic resource-scoped clients (`<service>/resource.rs`) alongside
303    /// the low-level per-service client and builders.
304    ///
305    /// A scoped client (e.g. `CatalogClient` bound to a name) captures the resource's name
306    /// components and exposes `get`/`update`/`delete` (+ resource-targeted custom RPCs) returning
307    /// the matching builder. Only emitted for resource-scoped services, and only when
308    /// [`client`](CodeGenOutput::client) is `Some`. Defaults to `false`.
309    pub generate_resource_clients: bool,
310    /// Output directory for Python bindings. Generation is skipped when `None`.
311    pub python: Option<PathBuf>,
312    /// Output directory for Node.js NAPI bindings. Generation is skipped when `None`.
313    pub node: Option<PathBuf>,
314    /// Output directory for Node.js TypeScript client. Generation is skipped when `None`.
315    pub node_ts: Option<PathBuf>,
316    /// Output directory for WASM/browser `#[wasm_bindgen]` bindings + `.d.ts`. Generation is
317    /// skipped when `None`.
318    ///
319    /// Emits a `#[wasm_bindgen]` wrapper layer over the generated (WASM-transport) clients plus a
320    /// `client.d.ts` for JS/TS consumers. Request/response values cross the boundary as plain JS
321    /// objects via `serde-wasm-bindgen`, so this pairs with `runtime: Buffa` (serde-native models)
322    /// and `transport_type_path = "olai_http_wasm::WasmClient"`.
323    pub wasm: Option<PathBuf>,
324    /// Filename for the generated Python typings stub.
325    ///
326    /// Default: `"client.pyi"`
327    pub python_typings_filename: String,
328}
329
330impl CodeGenOutput {
331    /// Absolute path of the generated subdirectory (`models/models_subdir`), if `models` is set.
332    pub fn models_subdir_path(&self) -> Option<PathBuf> {
333        self.models.as_ref().map(|m| m.join(&self.models_subdir))
334    }
335}