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