buffa_build/lib.rs
1//! Build-time integration for buffa.
2//!
3//! Use this crate in your `build.rs` to compile `.proto` files into Rust code
4//! at build time. Parses `.proto` files into a `FileDescriptorSet` (via
5//! `protoc` or `buf`), then uses `buffa-codegen` to generate Rust source.
6//!
7//! # Example
8//!
9//! ```rust,ignore
10//! // build.rs
11//! fn main() {
12//! buffa_build::Config::new()
13//! .files(&["proto/my_service.proto"])
14//! .includes(&["proto/"])
15//! .compile()
16//! .unwrap();
17//! }
18//! ```
19//!
20//! # Requirements
21//!
22//! By default, requires `protoc` on the system PATH (or set via the `PROTOC`
23//! environment variable) — the same as `prost-build` and `tonic-build`.
24//!
25//! If `protoc` is unavailable or outdated on your platform, `buf` can be
26//! used instead — see [`Config::use_buf()`]. Alternatively, feed a
27//! pre-compiled descriptor set via [`Config::descriptor_set()`].
28
29use std::path::{Path, PathBuf};
30use std::process::Command;
31
32use buffa::Message;
33use buffa_codegen::generated::descriptor::FileDescriptorSet;
34
35use buffa_codegen::CodeGenConfig;
36
37/// How to produce a `FileDescriptorSet` from `.proto` files.
38#[derive(Debug, Clone, Default)]
39enum DescriptorSource {
40 /// Invoke `protoc` (default). Requires `protoc` on PATH or `PROTOC` env var.
41 #[default]
42 Protoc,
43 /// Invoke `buf build --as-file-descriptor-set`. Requires `buf` on PATH.
44 Buf,
45 /// Read a pre-built `FileDescriptorSet` from a file.
46 Precompiled(PathBuf),
47}
48
49/// Builder for configuring and running protobuf compilation.
50pub struct Config {
51 files: Vec<PathBuf>,
52 includes: Vec<PathBuf>,
53 out_dir: Option<PathBuf>,
54 codegen_config: CodeGenConfig,
55 descriptor_source: DescriptorSource,
56 /// If set, generate a module-tree include file with this name in the
57 /// output directory. Users can then `include!` this single file instead
58 /// of manually setting up `pub mod` nesting.
59 include_file: Option<String>,
60}
61
62impl Config {
63 /// Create a new configuration with defaults.
64 pub fn new() -> Self {
65 Self {
66 files: Vec::new(),
67 includes: Vec::new(),
68 out_dir: None,
69 codegen_config: CodeGenConfig::default(),
70 descriptor_source: DescriptorSource::default(),
71 include_file: None,
72 }
73 }
74
75 /// Add `.proto` files to compile.
76 #[must_use]
77 pub fn files(mut self, files: &[impl AsRef<Path>]) -> Self {
78 self.files
79 .extend(files.iter().map(|f| f.as_ref().to_path_buf()));
80 self
81 }
82
83 /// Add include directories for protoc to search for imports.
84 #[must_use]
85 pub fn includes(mut self, includes: &[impl AsRef<Path>]) -> Self {
86 self.includes
87 .extend(includes.iter().map(|i| i.as_ref().to_path_buf()));
88 self
89 }
90
91 /// Set the output directory for generated files.
92 /// Defaults to `$OUT_DIR` if not set.
93 #[must_use]
94 pub fn out_dir(mut self, dir: impl Into<PathBuf>) -> Self {
95 self.out_dir = Some(dir.into());
96 self
97 }
98
99 /// Enable or disable view type generation (default: true).
100 #[must_use]
101 pub fn generate_views(mut self, enabled: bool) -> Self {
102 self.codegen_config.generate_views = enabled;
103 self
104 }
105
106 /// Enable or disable serde JSON generation (default: false).
107 ///
108 /// When enabled:
109 /// - Generated message structs get `Serialize`/`Deserialize` derives.
110 /// - Generated enum types get `Serialize`/`Deserialize` derives.
111 /// - Generated view types (when `generate_views` is also enabled) get a
112 /// manual `impl Serialize` for zero-copy JSON serialization, so
113 /// `serde_json::to_string(&view)` works directly:
114 ///
115 /// ```ignore
116 /// let view = MyMsgView::decode_view(&bytes)?;
117 /// let json = serde_json::to_string(&view)?;
118 /// ```
119 ///
120 /// The downstream crate must depend on `serde` and enable the `buffa/json`
121 /// feature for the runtime helpers. When views are enabled, the crate must
122 /// also enable `buffa-types/json` so the well-known type views implement
123 /// `Serialize`; without it, references to e.g. `TimestampView<'_>` in the
124 /// generated `Serialize` impl will fail with
125 /// `the trait bound 'TimestampView<'_>: Serialize' is not satisfied`.
126 ///
127 /// **Limitations of the view `Serialize` impl:**
128 /// - Extension fields are not included in view JSON output; serialize the
129 /// owned form (`view.to_owned_message()`) to include extensions.
130 /// - The impl uses `serialize_map(None)` (unknown length) because the
131 /// number of emitted fields depends on default-omission rules. Most
132 /// self-describing serializers (notably `serde_json`) accept this, but
133 /// length-prefixed formats (e.g. `bincode`, `postcard`) will return a
134 /// runtime error. The owned types' derived `Serialize` does not have this
135 /// restriction.
136 #[must_use]
137 pub fn generate_json(mut self, enabled: bool) -> Self {
138 self.codegen_config.generate_json = enabled;
139 self
140 }
141
142 /// Enable or disable `impl buffa::text::TextFormat` on generated message
143 /// structs (default: false).
144 ///
145 /// When enabled, the downstream crate must enable the `buffa/text`
146 /// feature for the runtime textproto encoder/decoder.
147 #[must_use]
148 pub fn generate_text(mut self, enabled: bool) -> Self {
149 self.codegen_config.generate_text = enabled;
150 self
151 }
152
153 /// Enable or disable `#[derive(arbitrary::Arbitrary)]` on generated
154 /// types (default: false).
155 ///
156 /// The derive is gated behind `#[cfg_attr(feature = "arbitrary", ...)]`
157 /// so the downstream crate compiles with or without the feature enabled.
158 ///
159 /// Your crate's Cargo feature **must be named exactly `"arbitrary"`** —
160 /// the generated `cfg_attr` uses that literal string and cannot be
161 /// customised — and it must forward to `buffa/arbitrary`:
162 ///
163 /// ```toml
164 /// [features]
165 /// arbitrary = ["dep:arbitrary", "buffa/arbitrary"]
166 /// ```
167 ///
168 /// Forgetting `"buffa/arbitrary"` produces a confusing
169 /// `cannot find function 'arbitrary_bytes' in module '__private'` error
170 /// in generated code when [`use_bytes_type`](Self::use_bytes_type) or
171 /// [`use_bytes_type_in`](Self::use_bytes_type_in) is also enabled,
172 /// because the helper that backs `#[arbitrary(with = ...)]` for
173 /// `bytes::Bytes` fields lives in `buffa` under that feature gate.
174 #[must_use]
175 pub fn generate_arbitrary(mut self, enabled: bool) -> Self {
176 self.codegen_config.generate_arbitrary = enabled;
177 self
178 }
179
180 /// Wrap generated `impl`s in `#[cfg(feature = "...")]` instead of
181 /// emitting them unconditionally (default: false).
182 ///
183 /// When enabled, the impls controlled by [`generate_json`],
184 /// [`generate_views`], and [`generate_text`] are wrapped in
185 /// `#[cfg(feature = "json" | "views" | "text")]` (or
186 /// `#[cfg_attr(feature = ..., ...)]` for derives and field attributes)
187 /// rather than emitted unconditionally. The crate consuming the
188 /// generated code must define matching Cargo features that enable the
189 /// corresponding runtime support:
190 ///
191 /// ```toml
192 /// [features]
193 /// json = ["buffa/json", "dep:serde", "dep:serde_json"]
194 /// views = []
195 /// text = ["buffa/text"]
196 /// ```
197 ///
198 /// The `generate_*` flags still control *whether* an impl kind is
199 /// emitted at all — this flag only controls whether it is `cfg`-gated.
200 /// `generate_arbitrary` is always `cfg_attr`-gated on
201 /// `feature = "arbitrary"` regardless of this flag, because `arbitrary`
202 /// is an optional dependency by design.
203 ///
204 /// Reach for this when generated code is the **public interface of a
205 /// library crate** consumed by downstream projects with different
206 /// feature needs — exactly the shape of `buffa-descriptor` and
207 /// `buffa-types`, which ship every impl while letting the codegen
208 /// toolchain (`buffa-codegen`/`buffa-build`/`protoc-gen-buffa`) depend
209 /// on them with `default-features = false` and stay free of
210 /// `serde`/`serde_json`/`base64`. Most consumers of `buffa-build` are
211 /// **not** in this position: a `build.rs` that decides at build-script
212 /// time whether to generate JSON wants `impl Serialize` to just exist.
213 /// Default `false`.
214 ///
215 /// [`generate_json`]: Self::generate_json
216 /// [`generate_views`]: Self::generate_views
217 /// [`generate_text`]: Self::generate_text
218 #[must_use]
219 pub fn gate_impls_on_crate_features(mut self, enabled: bool) -> Self {
220 self.codegen_config.gate_impls_on_crate_features = enabled;
221 self
222 }
223
224 /// Enable or disable `with_*` builder-style setter methods for
225 /// explicit-presence fields (default: true).
226 ///
227 /// Each explicit-presence scalar, bytes, or enum field gets a
228 /// `pub fn with_<name>(mut self, value: T) -> Self` method that wraps the
229 /// value in `Some(...)` and returns `self`, enabling chained construction
230 /// without the `Some(...)` boilerplate:
231 ///
232 /// ```ignore
233 /// let req = MyRequest::default()
234 /// .with_name("alice")
235 /// .with_timeout_ms(30_000);
236 /// ```
237 ///
238 /// String, bytes, and enum setters take `impl Into<T>` (so `&str`,
239 /// `b"..."` literals, and bare enum variants work directly); other
240 /// scalars take `T` to keep integer-literal inference unambiguous.
241 ///
242 /// Setters are pure inherent methods with no runtime dependency — they
243 /// don't interact with the `json`/`views`/`text` feature gates. Disable
244 /// only if you want to keep generated code minimal or have a competing
245 /// `with_*` convention in your own crate.
246 #[must_use]
247 pub fn generate_with_setters(mut self, enabled: bool) -> Self {
248 self.codegen_config.generate_with_setters = enabled;
249 self
250 }
251
252 /// Enable or disable unknown field preservation (default: true).
253 ///
254 /// When enabled (the default), unrecognized fields encountered during
255 /// decode are stored and re-emitted on encode — essential for proxy /
256 /// middleware services and round-trip fidelity across schema versions.
257 ///
258 /// **Disabling is primarily a memory optimization** (24 bytes/message for
259 /// the `UnknownFields` Vec header), not a throughput one. When no unknown
260 /// fields appear on the wire — the common case for schema-aligned
261 /// services — decode and encode costs are effectively identical in
262 /// either mode. Consider disabling for embedded / `no_std` targets or
263 /// large in-memory collections of small messages.
264 #[must_use]
265 pub fn preserve_unknown_fields(mut self, enabled: bool) -> Self {
266 self.codegen_config.preserve_unknown_fields = enabled;
267 self
268 }
269
270 /// Honor `features.utf8_validation = NONE` by emitting `Vec<u8>` / `&[u8]`
271 /// for such string fields instead of `String` / `&str` (default: false).
272 ///
273 /// When disabled (the default), all string fields map to `String` and
274 /// UTF-8 is validated on decode — stricter than proto2 requires, but
275 /// ergonomic and safe.
276 ///
277 /// When enabled, string fields with `utf8_validation = NONE` become
278 /// `Vec<u8>` / `&[u8]`. Decode skips validation; the caller chooses
279 /// whether to `std::str::from_utf8` (checked) or `from_utf8_unchecked`
280 /// (trusted-input fast path). This is the only sound Rust mapping when
281 /// strings may actually contain non-UTF-8 bytes.
282 ///
283 /// **Note for proto2 users**: proto2's default is `utf8_validation = NONE`,
284 /// so enabling this turns ALL proto2 string fields into `Vec<u8>`. Use
285 /// only for new code or when profiling identifies UTF-8 validation as a
286 /// bottleneck (it can be 10%+ of decode CPU for string-heavy messages).
287 ///
288 /// **JSON note**: fields normalized to bytes serialize as base64 in JSON
289 /// (the proto3 JSON encoding for `bytes`). Keep strict mapping disabled
290 /// for fields that need JSON string interop with other implementations.
291 #[must_use]
292 pub fn strict_utf8_mapping(mut self, enabled: bool) -> Self {
293 self.codegen_config.strict_utf8_mapping = enabled;
294 self
295 }
296
297 /// Permit `option message_set_wire_format = true` on input messages.
298 ///
299 /// MessageSet is a legacy Google-internal wire format. Default: `false`
300 /// (such messages produce a codegen error). Set to `true` only when
301 /// compiling protos that interoperate with old Google-internal services.
302 #[must_use]
303 pub fn allow_message_set(mut self, enabled: bool) -> Self {
304 self.codegen_config.allow_message_set = enabled;
305 self
306 }
307
308 /// Declare an external type path mapping.
309 ///
310 /// Types under the given protobuf path prefix will reference the specified
311 /// Rust module path instead of being generated. This allows shared proto
312 /// packages to be compiled once in a dedicated crate and referenced from
313 /// others.
314 ///
315 /// `proto_path` is a fully-qualified protobuf package path, e.g.,
316 /// `".my.common"` or `"my.common"` (the leading dot is optional and will
317 /// be added automatically). `rust_path` is the Rust module path where
318 /// those types are accessible (e.g., `"::common_protos"`).
319 ///
320 /// # Example
321 ///
322 /// ```rust,ignore
323 /// buffa_build::Config::new()
324 /// .extern_path(".my.common", "::common_protos")
325 /// .files(&["proto/my_service.proto"])
326 /// .includes(&["proto/"])
327 /// .compile()
328 /// .unwrap();
329 /// ```
330 #[must_use]
331 pub fn extern_path(
332 mut self,
333 proto_path: impl Into<String>,
334 rust_path: impl Into<String>,
335 ) -> Self {
336 let mut proto_path = proto_path.into();
337 // Normalize: ensure the proto path is fully-qualified (leading dot).
338 // Accept both ".my.package" and "my.package" for convenience.
339 if !proto_path.starts_with('.') {
340 proto_path.insert(0, '.');
341 }
342 self.codegen_config
343 .extern_paths
344 .push((proto_path, rust_path.into()));
345 self
346 }
347
348 /// Configure `bytes` fields to use `bytes::Bytes` instead of `Vec<u8>`.
349 ///
350 /// Each path is a fully-qualified proto path prefix. Use `"."` to apply
351 /// to all bytes fields, or specify individual field paths like
352 /// `".my.pkg.MyMessage.data"`.
353 ///
354 /// # Example
355 ///
356 /// ```rust,ignore
357 /// buffa_build::Config::new()
358 /// .bytes(&["."]) // all bytes fields use Bytes
359 /// .files(&["proto/my_service.proto"])
360 /// .includes(&["proto/"])
361 /// .compile()
362 /// .unwrap();
363 /// ```
364 #[must_use]
365 pub fn use_bytes_type_in(mut self, paths: &[impl AsRef<str>]) -> Self {
366 self.codegen_config
367 .bytes_fields
368 .extend(paths.iter().map(|p| p.as_ref().to_string()));
369 self
370 }
371
372 /// Use `bytes::Bytes` for all `bytes` fields in all messages.
373 ///
374 /// This is a convenience for `.use_bytes_type_in(&["."])`. Use `.use_bytes_type_in(&[...])` with
375 /// specific proto paths if you only want `Bytes` for certain fields.
376 #[must_use]
377 pub fn use_bytes_type(mut self) -> Self {
378 self.codegen_config.bytes_fields.push(".".to_string());
379 self
380 }
381
382 /// Add a custom attribute to generated types (messages and enums)
383 /// matching a proto path prefix.
384 ///
385 /// `path` is a fully-qualified proto path prefix: `"."` applies to all
386 /// types, `".my.pkg"` to types in that package, `".my.pkg.MyMessage"`
387 /// to a specific type. A leading `.` is auto-prepended if omitted; a
388 /// trailing `.` is trimmed. Prefix matching respects proto-segment
389 /// boundaries, so `".my.pk"` does not match `".my.pkg.Msg"`.
390 ///
391 /// `attribute` is a raw Rust attribute string
392 /// (e.g., `"#[derive(serde::Serialize)]"`). A malformed attribute
393 /// produces [`CodeGenError::InvalidCustomAttribute`](buffa_codegen::CodeGenError)
394 /// at compile time rather than being silently dropped.
395 ///
396 /// Multiple calls accumulate in insertion order — all matching attributes
397 /// are emitted, and ordering is preserved in generated code.
398 ///
399 /// Also applies to generated oneof enums when `path` matches
400 /// `".pkg.Msg.my_oneof"` (the oneof's fully-qualified path).
401 ///
402 /// # Pitfalls
403 ///
404 /// buffa already emits `#[derive(Clone, PartialEq)]` on messages and
405 /// `#[derive(Clone, PartialEq, Debug)]` on oneofs; adding a duplicate
406 /// derive via `type_attribute(".", "#[derive(Clone)]")` produces a
407 /// compile error in the generated code.
408 ///
409 /// # Example
410 ///
411 /// ```rust,ignore
412 /// buffa_build::Config::new()
413 /// .type_attribute(".", "#[derive(serde::Serialize)]")
414 /// .type_attribute(".my.pkg.MyEnum", "#[derive(strum::EnumIter)]")
415 /// .files(&["proto/my_service.proto"])
416 /// .includes(&["proto/"])
417 /// .compile()
418 /// .unwrap();
419 /// ```
420 #[must_use]
421 pub fn type_attribute(mut self, path: impl Into<String>, attribute: impl Into<String>) -> Self {
422 self.codegen_config
423 .type_attributes
424 .push((normalize_attr_path(path.into()), attribute.into()));
425 self
426 }
427
428 /// Add a custom attribute to generated struct fields matching a proto
429 /// path prefix.
430 ///
431 /// `path` is a fully-qualified proto field path (e.g.,
432 /// `".my.pkg.MyMessage.my_field"`). `"."` applies to all fields. A
433 /// leading `.` is auto-prepended if omitted; a trailing `.` is trimmed.
434 /// Prefix matching respects proto-segment boundaries.
435 ///
436 /// Also applies to oneof variants when `path` matches
437 /// `".pkg.Msg.my_oneof.variant_name"`.
438 ///
439 /// # Example
440 ///
441 /// ```rust,ignore
442 /// buffa_build::Config::new()
443 /// .field_attribute(".my.pkg.MyMessage.secret_key", "#[serde(skip)]")
444 /// .files(&["proto/my_service.proto"])
445 /// .includes(&["proto/"])
446 /// .compile()
447 /// .unwrap();
448 /// ```
449 #[must_use]
450 pub fn field_attribute(
451 mut self,
452 path: impl Into<String>,
453 attribute: impl Into<String>,
454 ) -> Self {
455 self.codegen_config
456 .field_attributes
457 .push((normalize_attr_path(path.into()), attribute.into()));
458 self
459 }
460
461 /// Add a custom attribute to generated message structs only (not enums,
462 /// not oneof enums) matching a proto path prefix.
463 ///
464 /// Same path-matching semantics as [`type_attribute`](Self::type_attribute) —
465 /// leading `.` auto-prepended, trailing `.` trimmed, proto-segment-aware
466 /// prefix matching, accumulation in insertion order. A malformed attribute
467 /// produces a compile-time error. Useful for struct-only attributes like
468 /// `#[serde(default)]`.
469 ///
470 /// # Example
471 ///
472 /// ```rust,ignore
473 /// buffa_build::Config::new()
474 /// .message_attribute(".", "#[serde(default)]")
475 /// .files(&["proto/my_service.proto"])
476 /// .includes(&["proto/"])
477 /// .compile()
478 /// .unwrap();
479 /// ```
480 #[must_use]
481 pub fn message_attribute(
482 mut self,
483 path: impl Into<String>,
484 attribute: impl Into<String>,
485 ) -> Self {
486 self.codegen_config
487 .message_attributes
488 .push((normalize_attr_path(path.into()), attribute.into()));
489 self
490 }
491
492 /// Add a custom attribute to generated enum types only (not message
493 /// structs, not oneof enums) matching a proto path prefix.
494 ///
495 /// Same path-matching semantics as [`type_attribute`](Self::type_attribute) —
496 /// leading `.` auto-prepended, trailing `.` trimmed, proto-segment-aware
497 /// prefix matching, accumulation in insertion order. A malformed attribute
498 /// produces a compile-time error. Useful when you want to inject an
499 /// attribute on every enum in a package without also matching the
500 /// (often more numerous) messages that share the path prefix — e.g.
501 /// `#[derive(strum::EnumIter)]`, which only makes sense on enums.
502 ///
503 /// # Example
504 ///
505 /// ```rust,ignore
506 /// buffa_build::Config::new()
507 /// .enum_attribute(".my.pkg", "#[derive(strum::EnumIter)]")
508 /// .files(&["proto/my_service.proto"])
509 /// .includes(&["proto/"])
510 /// .compile()
511 /// .unwrap();
512 /// ```
513 #[must_use]
514 pub fn enum_attribute(mut self, path: impl Into<String>, attribute: impl Into<String>) -> Self {
515 self.codegen_config
516 .enum_attributes
517 .push((normalize_attr_path(path.into()), attribute.into()));
518 self
519 }
520
521 /// Use `buf build` instead of `protoc` for descriptor generation.
522 ///
523 /// `buf` is often easier to install and keep current than `protoc`
524 /// (which many distros pin to old versions). This mode is intended for
525 /// the **single-crate case**: a `buf.yaml` at the crate root defining
526 /// the module layout.
527 ///
528 /// Requires `buf` on PATH and a `buf.yaml` at the crate root. The
529 /// [`includes()`](Self::includes) setting is ignored — buf resolves
530 /// imports via its own module configuration.
531 ///
532 /// Each path given to [`files()`](Self::files) must be **relative to its
533 /// owning module's directory** (the `path:` value inside `buf.yaml`), not
534 /// the crate root where `buf.yaml` itself lives. buf strips the module
535 /// path when producing `FileDescriptorProto.name`, so for
536 /// `modules: [{path: proto}]` and a file on disk at
537 /// `proto/api/v1/service.proto`, the descriptor name is
538 /// `api/v1/service.proto` — that is what `.files()` must contain.
539 /// Multiple modules in one `buf.yaml` work fine; buf enforces that
540 /// module-relative names are unique across the workspace.
541 ///
542 /// # Monorepo / multi-module setups
543 ///
544 /// For a workspace-root `buf.yaml` with many modules, this mode is a
545 /// poor fit. Prefer running `buf generate` with the `protoc-gen-buffa`
546 /// plugin and checking in the generated code, or use
547 /// [`descriptor_set()`](Self::descriptor_set) with the output of
548 /// `buf build --as-file-descriptor-set -o fds.binpb <module-path>`
549 /// run as a pre-build step.
550 ///
551 /// # Example
552 ///
553 /// ```rust,ignore
554 /// // buf.yaml (at crate root):
555 /// // version: v2
556 /// // modules:
557 /// // - path: proto
558 /// //
559 /// // build.rs:
560 /// buffa_build::Config::new()
561 /// .use_buf()
562 /// .files(&["api/v1/service.proto"]) // relative to module root
563 /// .compile()
564 /// .unwrap();
565 /// ```
566 #[must_use]
567 pub fn use_buf(mut self) -> Self {
568 self.descriptor_source = DescriptorSource::Buf;
569 self
570 }
571
572 /// Use a pre-compiled `FileDescriptorSet` binary file as input.
573 ///
574 /// Skips invoking `protoc` or `buf` entirely. The file must contain a
575 /// serialized `google.protobuf.FileDescriptorSet` (as produced by
576 /// `protoc --descriptor_set_out` or `buf build --as-file-descriptor-set`).
577 ///
578 /// When using this, `.files()` specifies which proto files in the
579 /// descriptor set to generate code for (matching by proto file name).
580 #[must_use]
581 pub fn descriptor_set(mut self, path: impl Into<PathBuf>) -> Self {
582 self.descriptor_source = DescriptorSource::Precompiled(path.into());
583 self
584 }
585
586 /// Generate a module-tree include file alongside the per-package `.rs`
587 /// files.
588 ///
589 /// The include file contains nested `pub mod` declarations with
590 /// `include!()` directives that assemble the generated code into a
591 /// module hierarchy matching the protobuf package structure. Users can
592 /// then include this single file instead of manually creating the
593 /// module tree.
594 ///
595 /// The form of the emitted `include!` directives depends on whether
596 /// [`out_dir`](Self::out_dir) was set:
597 ///
598 /// - **Default (`$OUT_DIR`)**: emits
599 /// `include!(concat!(env!("OUT_DIR"), "/foo.rs"))`, for use from
600 /// `build.rs` via `include!(concat!(env!("OUT_DIR"), "/<name>"))`.
601 /// - **Explicit `out_dir`**: emits sibling-relative `include!("foo.rs")`,
602 /// for checking the generated code into the source tree and referencing
603 /// it as a module (e.g. `mod gen;`).
604 ///
605 /// # Example — `build.rs` / `$OUT_DIR`
606 ///
607 /// ```rust,ignore
608 /// // build.rs
609 /// buffa_build::Config::new()
610 /// .files(&["proto/my_service.proto"])
611 /// .includes(&["proto/"])
612 /// .include_file("_include.rs")
613 /// .compile()
614 /// .unwrap();
615 ///
616 /// // lib.rs
617 /// include!(concat!(env!("OUT_DIR"), "/_include.rs"));
618 /// ```
619 ///
620 /// # Example — checked-in source
621 ///
622 /// ```rust,ignore
623 /// // codegen.rs (run manually, not from build.rs)
624 /// buffa_build::Config::new()
625 /// .files(&["proto/my_service.proto"])
626 /// .includes(&["proto/"])
627 /// .out_dir("src/gen")
628 /// .include_file("mod.rs")
629 /// .compile()
630 /// .unwrap();
631 ///
632 /// // lib.rs
633 /// mod gen;
634 /// ```
635 #[must_use]
636 pub fn include_file(mut self, name: impl Into<String>) -> Self {
637 self.include_file = Some(name.into());
638 self
639 }
640
641 /// Compile proto files and generate Rust source.
642 ///
643 /// # Errors
644 ///
645 /// Returns an error if:
646 /// - `OUT_DIR` is not set and no `out_dir` was configured
647 /// - `protoc` or `buf` cannot be found on `PATH` (when using those sources)
648 /// - the proto compiler exits with a non-zero status (syntax errors,
649 /// missing imports, etc.)
650 /// - a precompiled descriptor set file cannot be read
651 /// - the descriptor set bytes cannot be decoded as a `FileDescriptorSet`
652 /// - code generation fails (e.g. unsupported proto feature)
653 /// - the output directory cannot be created or written to
654 pub fn compile(self) -> Result<(), Box<dyn std::error::Error>> {
655 // When out_dir is explicitly set, the include file should use
656 // relative `include!("foo.rs")` paths (the index is a sibling of the
657 // generated files). When defaulted to $OUT_DIR, keep the
658 // `concat!(env!("OUT_DIR"), ...)` form so that
659 // `include!(concat!(env!("OUT_DIR"), "/_include.rs"))` from src/
660 // still resolves to absolute paths.
661 let relative_includes = self.out_dir.is_some();
662 let out_dir = self
663 .out_dir
664 .or_else(|| std::env::var("OUT_DIR").ok().map(PathBuf::from))
665 .ok_or("OUT_DIR not set and no out_dir configured")?;
666
667 // Produce a FileDescriptorSet from the configured source.
668 let descriptor_bytes = match &self.descriptor_source {
669 DescriptorSource::Protoc => invoke_protoc(&self.files, &self.includes)?,
670 DescriptorSource::Buf => invoke_buf()?,
671 DescriptorSource::Precompiled(path) => std::fs::read(path).map_err(|e| {
672 format!("failed to read descriptor set '{}': {}", path.display(), e)
673 })?,
674 };
675 let fds = FileDescriptorSet::decode_from_slice(&descriptor_bytes)
676 .map_err(|e| format!("failed to decode FileDescriptorSet: {}", e))?;
677
678 // Determine which files were explicitly requested.
679 //
680 // `FileDescriptorProto.name` contains the path relative to the proto
681 // source root (protoc: `--proto_path`; buf: the module root). For
682 // Precompiled and Buf mode, `.files()` are expected to already be
683 // proto-relative names. For Protoc mode, strip the longest matching
684 // include prefix.
685 let files_to_generate: Vec<String> = if matches!(
686 self.descriptor_source,
687 DescriptorSource::Precompiled(_) | DescriptorSource::Buf
688 ) {
689 self.files
690 .iter()
691 .filter_map(|f| f.to_str().map(str::to_string))
692 .collect()
693 } else {
694 self.files
695 .iter()
696 .map(|f| proto_relative_name(f, &self.includes))
697 .filter(|s| !s.is_empty())
698 .collect()
699 };
700
701 // Generate Rust source. Per-proto content files plus a per-package
702 // `.mod.rs` stitcher; only the stitchers need wiring into the
703 // module tree (content files are reached via `include!` from
704 // there).
705 let generated =
706 buffa_codegen::generate(&fds.file, &files_to_generate, &self.codegen_config)?;
707
708 // Write output files; collect (name, package) for PackageMod entries.
709 let mut output_entries: Vec<(String, String)> = Vec::new();
710 for file in generated {
711 let path = out_dir.join(&file.name);
712 if let Some(parent) = path.parent() {
713 std::fs::create_dir_all(parent)?;
714 }
715 write_if_changed(&path, file.content.as_bytes())?;
716 if file.kind == buffa_codegen::GeneratedFileKind::PackageMod {
717 output_entries.push((file.name, file.package));
718 }
719 }
720
721 // Generate the include file if requested.
722 if let Some(ref include_name) = self.include_file {
723 let include_content = generate_include_file(&output_entries, relative_includes);
724 let include_path = out_dir.join(include_name);
725 write_if_changed(&include_path, include_content.as_bytes())?;
726 }
727
728 // Tell cargo to re-run if any proto file changes.
729 //
730 // For Buf mode, `self.files` are module-root-relative and cargo can't
731 // stat them — use `buf ls-files` instead, which lists all workspace
732 // protos with workspace-relative paths. This also catches changes to
733 // transitively-imported protos (a gap in the Protoc mode, which only
734 // watches explicitly-listed files).
735 match self.descriptor_source {
736 DescriptorSource::Buf => emit_buf_rerun_if_changed(),
737 DescriptorSource::Protoc => {
738 // Rerun if PROTOC changes (different binary may accept
739 // protos the previous one rejected, e.g. newer editions).
740 println!("cargo:rerun-if-env-changed=PROTOC");
741 for proto_file in &self.files {
742 println!("cargo:rerun-if-changed={}", proto_file.display());
743 }
744 }
745 DescriptorSource::Precompiled(ref path) => {
746 println!("cargo:rerun-if-changed={}", path.display());
747 }
748 }
749
750 Ok(())
751 }
752}
753
754impl Default for Config {
755 fn default() -> Self {
756 Self::new()
757 }
758}
759
760/// Normalize a user-supplied attribute-match path.
761///
762/// - Prepends `.` if absent so all stored paths are rooted.
763/// - Trims trailing `.` so `".my.pkg."` and `".my.pkg"` behave identically
764/// (trailing-dot patterns otherwise never match a real FQN).
765/// - The bare catch-all `"."` is preserved as-is.
766fn normalize_attr_path(mut path: String) -> String {
767 if !path.starts_with('.') {
768 path.insert(0, '.');
769 }
770 if path.len() > 1 {
771 while path.ends_with('.') {
772 path.pop();
773 }
774 }
775 path
776}
777
778/// Write `content` to `path` only if the file doesn't already exist with
779/// identical content. Avoids bumping timestamps on unchanged files, which
780/// prevents unnecessary downstream recompilation.
781fn write_if_changed(path: &Path, content: &[u8]) -> std::io::Result<()> {
782 if let Ok(existing) = std::fs::read(path) {
783 if existing == content {
784 return Ok(());
785 }
786 }
787 std::fs::write(path, content)
788}
789
790/// Invoke `protoc` to produce a `FileDescriptorSet` (serialized bytes).
791fn invoke_protoc(
792 files: &[PathBuf],
793 includes: &[PathBuf],
794) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
795 let protoc = std::env::var("PROTOC").unwrap_or_else(|_| "protoc".to_string());
796
797 let descriptor_file =
798 tempfile::NamedTempFile::new().map_err(|e| format!("failed to create temp file: {}", e))?;
799 let descriptor_path = descriptor_file.path().to_path_buf();
800
801 let mut cmd = Command::new(&protoc);
802 cmd.arg("--include_imports");
803 cmd.arg("--include_source_info");
804 cmd.arg(format!(
805 "--descriptor_set_out={}",
806 descriptor_path.display()
807 ));
808
809 for include in includes {
810 cmd.arg(format!("--proto_path={}", include.display()));
811 }
812
813 for file in files {
814 cmd.arg(file.as_os_str());
815 }
816
817 let output = cmd
818 .output()
819 .map_err(|e| format!("failed to run protoc ({}): {}", protoc, e))?;
820
821 if !output.status.success() {
822 let stderr = String::from_utf8_lossy(&output.stderr);
823 return Err(format!("protoc failed: {}", stderr).into());
824 }
825
826 let bytes = std::fs::read(&descriptor_path)
827 .map_err(|e| format!("failed to read descriptor set: {}", e))?;
828
829 Ok(bytes)
830}
831
832/// Invoke `buf build` to produce a `FileDescriptorSet` (serialized bytes).
833///
834/// Requires a `buf.yaml` discoverable from the build script's cwd. Builds
835/// the entire workspace — no `--path` filtering, because buf's `--path` flag
836/// expects workspace-relative paths while `FileDescriptorProto.name` is
837/// module-root-relative; passing user paths to both would be a contradiction.
838/// Codegen filtering happens on our side via `files_to_generate` matching.
839fn invoke_buf() -> Result<Vec<u8>, Box<dyn std::error::Error>> {
840 // buf build includes SourceCodeInfo by default (there's an
841 // --exclude-source-info flag to disable it), so proto comments
842 // propagate to generated code without an explicit opt-in here.
843 let output = Command::new("buf")
844 .arg("build")
845 .arg("--as-file-descriptor-set")
846 .arg("-o")
847 .arg("-")
848 .output()
849 .map_err(|e| format!("failed to run buf (is it installed and on PATH?): {e}"))?;
850
851 if !output.status.success() {
852 let stderr = String::from_utf8_lossy(&output.stderr);
853 return Err(
854 format!("buf build failed (is buf.yaml present at crate root?): {stderr}").into(),
855 );
856 }
857
858 Ok(output.stdout)
859}
860
861/// Emit `cargo:rerun-if-changed` directives for a buf workspace.
862///
863/// Runs `buf ls-files` to discover all proto files with workspace-relative
864/// paths (which cargo can stat). Also watches `buf.yaml` and `buf.lock`
865/// (the latter only if it exists — cargo treats a missing rerun-if-changed
866/// path as always-dirty). Failure is non-fatal: worst case cargo reruns
867/// every build.
868fn emit_buf_rerun_if_changed() {
869 println!("cargo:rerun-if-changed=buf.yaml");
870 if Path::new("buf.lock").exists() {
871 println!("cargo:rerun-if-changed=buf.lock");
872 }
873 match Command::new("buf").arg("ls-files").output() {
874 Ok(out) if out.status.success() => {
875 for line in String::from_utf8_lossy(&out.stdout).lines() {
876 let path = line.trim();
877 if !path.is_empty() {
878 println!("cargo:rerun-if-changed={path}");
879 }
880 }
881 }
882 _ => {
883 // ls-files failed; cargo already knows about buf.yaml above.
884 // If buf itself is missing, invoke_buf() will error clearly.
885 }
886 }
887}
888
889/// Convert a filesystem proto path to the name protoc uses in the descriptor.
890///
891/// `FileDescriptorProto.name` is relative to the `--proto_path` include
892/// directory. This strips the longest matching include prefix; if no include
893/// matches, returns the path as-is (not just file_name — that would break
894/// nested proto directories).
895fn proto_relative_name(file: &Path, includes: &[PathBuf]) -> String {
896 // Longest prefix wins: a file under both "proto/" and "proto/vendor/"
897 // should strip "proto/vendor/" for a correct relative name.
898 let mut best: Option<&Path> = None;
899 for include in includes {
900 if let Ok(rel) = file.strip_prefix(include) {
901 match best {
902 Some(prev) if prev.as_os_str().len() <= rel.as_os_str().len() => {}
903 _ => best = Some(rel),
904 }
905 }
906 }
907 best.unwrap_or(file).to_str().unwrap_or("").to_string()
908}
909
910/// Generate the content of an include file that assembles generated `.rs`
911/// files into a nested module tree matching the protobuf package hierarchy.
912///
913/// Each generated file is named like `my.package.file_name.rs`. The package
914/// segments become `pub mod` wrappers, and the file is `include!`d inside
915/// the innermost module.
916///
917/// For example, files `["foo.bar.rs", "foo.baz.rs"]` produce:
918/// ```text
919/// pub mod foo {
920/// #[allow(unused_imports)]
921/// use super::*;
922/// include!(concat!(env!("OUT_DIR"), "/foo.bar.rs"));
923/// include!(concat!(env!("OUT_DIR"), "/foo.baz.rs"));
924/// }
925/// ```
926///
927/// When `relative` is true (the caller set [`Config::out_dir`] explicitly),
928/// `include!` directives use bare sibling paths (`include!("foo.bar.rs")`)
929/// instead of the `env!("OUT_DIR")` prefix, so the include file works when
930/// checked into the source tree and referenced via `mod`.
931fn generate_include_file(entries: &[(String, String)], relative: bool) -> String {
932 let mode = if relative {
933 buffa_codegen::IncludeMode::Relative("")
934 } else {
935 buffa_codegen::IncludeMode::OutDir
936 };
937 // Inner-allow off: this output is consumed via `include!` from
938 // user-authored `lib.rs`, where `#![allow(...)]` is not valid.
939 buffa_codegen::generate_module_tree(entries, mode, false)
940}
941
942#[cfg(test)]
943mod tests {
944 use super::*;
945
946 #[test]
947 fn proto_relative_name_strips_include() {
948 let got = proto_relative_name(
949 Path::new("proto/my/service.proto"),
950 &[PathBuf::from("proto/")],
951 );
952 assert_eq!(got, "my/service.proto");
953 }
954
955 #[test]
956 fn proto_relative_name_longest_prefix_wins() {
957 // Overlapping includes: file under both proto/ and proto/vendor/.
958 // Must strip the LONGER prefix for the correct relative name.
959 let got = proto_relative_name(
960 Path::new("proto/vendor/ext.proto"),
961 &[PathBuf::from("proto/"), PathBuf::from("proto/vendor/")],
962 );
963 assert_eq!(got, "ext.proto");
964 // Same with reversed include order.
965 let got = proto_relative_name(
966 Path::new("proto/vendor/ext.proto"),
967 &[PathBuf::from("proto/vendor/"), PathBuf::from("proto/")],
968 );
969 assert_eq!(got, "ext.proto");
970 }
971
972 #[test]
973 fn proto_relative_name_no_match_returns_full_path() {
974 // Regression: previously fell back to file_name(), which stripped
975 // directory components and broke descriptor_set() mode with nested
976 // proto packages. Now returns the full path as-is.
977 let got = proto_relative_name(Path::new("my/pkg/service.proto"), &[]);
978 assert_eq!(got, "my/pkg/service.proto");
979 }
980
981 #[test]
982 fn proto_relative_name_no_match_with_unrelated_includes() {
983 let got = proto_relative_name(
984 Path::new("src/my.proto"),
985 &[PathBuf::from("other/"), PathBuf::from("third/")],
986 );
987 assert_eq!(got, "src/my.proto");
988 }
989
990 #[test]
991 fn include_file_out_dir_mode_uses_env_var() {
992 let entries = vec![
993 ("foo.bar.rs".to_string(), "foo".to_string()),
994 ("root.rs".to_string(), String::new()),
995 ];
996 let out = generate_include_file(&entries, false);
997 assert!(
998 out.contains(r#"include!(concat!(env!("OUT_DIR"), "/foo.bar.rs"));"#),
999 "nested-package file should use env!(OUT_DIR): {out}"
1000 );
1001 assert!(
1002 out.contains(r#"include!(concat!(env!("OUT_DIR"), "/root.rs"));"#),
1003 "empty-package file should use env!(OUT_DIR): {out}"
1004 );
1005 assert!(!out.contains(r#"include!("foo.bar.rs")"#));
1006 }
1007
1008 #[test]
1009 fn include_file_relative_mode_uses_sibling_paths() {
1010 let entries = vec![
1011 ("foo.bar.rs".to_string(), "foo".to_string()),
1012 ("root.rs".to_string(), String::new()),
1013 ];
1014 let out = generate_include_file(&entries, true);
1015 assert!(
1016 out.contains(r#"include!("foo.bar.rs");"#),
1017 "nested-package file should use relative path: {out}"
1018 );
1019 assert!(
1020 out.contains(r#"include!("root.rs");"#),
1021 "empty-package file should use relative path: {out}"
1022 );
1023 assert!(
1024 !out.contains("OUT_DIR"),
1025 "relative mode must not reference OUT_DIR: {out}"
1026 );
1027 }
1028
1029 #[test]
1030 fn include_file_relative_mode_nested_packages() {
1031 // Two files in the same depth-2 package: verifies the relative flag
1032 // propagates through recursive emit() calls and both files land in
1033 // the same innermost mod.
1034 let entries = vec![
1035 ("a.b.one.rs".to_string(), "a.b".to_string()),
1036 ("a.b.two.rs".to_string(), "a.b".to_string()),
1037 ];
1038 let out = generate_include_file(&entries, true);
1039 // Both includes should appear once, at the same depth-2 indent,
1040 // inside a single `pub mod b { ... }`.
1041 let indent = " "; // depth 2 = 8 spaces
1042 assert!(
1043 out.contains(&format!(r#"{indent}include!("a.b.one.rs");"#)),
1044 "first file at depth 2: {out}"
1045 );
1046 assert!(
1047 out.contains(&format!(r#"{indent}include!("a.b.two.rs");"#)),
1048 "second file at depth 2: {out}"
1049 );
1050 assert_eq!(
1051 out.matches("pub mod b {").count(),
1052 1,
1053 "both files share one `mod b`: {out}"
1054 );
1055 assert!(!out.contains("OUT_DIR"));
1056 }
1057
1058 #[test]
1059 fn write_if_changed_creates_new_file() {
1060 let dir = tempfile::tempdir().unwrap();
1061 let path = dir.path().join("new.rs");
1062 write_if_changed(&path, b"hello").unwrap();
1063 assert_eq!(std::fs::read(&path).unwrap(), b"hello");
1064 }
1065
1066 #[test]
1067 fn write_if_changed_skips_identical_content() {
1068 let dir = tempfile::tempdir().unwrap();
1069 let path = dir.path().join("same.rs");
1070 std::fs::write(&path, b"content").unwrap();
1071 let mtime_before = std::fs::metadata(&path).unwrap().modified().unwrap();
1072
1073 // Sleep briefly so any write would produce a different mtime.
1074 std::thread::sleep(std::time::Duration::from_millis(50));
1075
1076 write_if_changed(&path, b"content").unwrap();
1077 let mtime_after = std::fs::metadata(&path).unwrap().modified().unwrap();
1078 assert_eq!(mtime_before, mtime_after);
1079 }
1080
1081 #[test]
1082 fn write_if_changed_overwrites_different_content() {
1083 let dir = tempfile::tempdir().unwrap();
1084 let path = dir.path().join("changed.rs");
1085 std::fs::write(&path, b"old").unwrap();
1086
1087 write_if_changed(&path, b"new").unwrap();
1088 assert_eq!(std::fs::read(&path).unwrap(), b"new");
1089 }
1090
1091 #[test]
1092 fn normalize_attr_path_prepends_leading_dot() {
1093 assert_eq!(normalize_attr_path("my.pkg".into()), ".my.pkg");
1094 }
1095
1096 #[test]
1097 fn normalize_attr_path_preserves_leading_dot() {
1098 assert_eq!(normalize_attr_path(".my.pkg".into()), ".my.pkg");
1099 }
1100
1101 #[test]
1102 fn normalize_attr_path_trims_trailing_dot() {
1103 assert_eq!(normalize_attr_path("my.pkg.".into()), ".my.pkg");
1104 assert_eq!(normalize_attr_path(".my.pkg.".into()), ".my.pkg");
1105 assert_eq!(normalize_attr_path(".my.pkg...".into()), ".my.pkg");
1106 }
1107
1108 #[test]
1109 fn normalize_attr_path_preserves_catchall() {
1110 assert_eq!(normalize_attr_path(".".into()), ".");
1111 assert_eq!(normalize_attr_path("".into()), ".");
1112 }
1113
1114 #[test]
1115 fn type_attribute_forwards_normalized_path() {
1116 let cfg = Config::new().type_attribute("my.pkg.", "#[derive(Foo)]");
1117 assert_eq!(
1118 cfg.codegen_config.type_attributes,
1119 vec![(".my.pkg".to_string(), "#[derive(Foo)]".to_string())]
1120 );
1121 }
1122
1123 #[test]
1124 fn field_attribute_forwards_normalized_path() {
1125 let cfg = Config::new().field_attribute("pkg.Msg.f", "#[serde(skip)]");
1126 assert_eq!(
1127 cfg.codegen_config.field_attributes,
1128 vec![(".pkg.Msg.f".to_string(), "#[serde(skip)]".to_string())]
1129 );
1130 }
1131
1132 #[test]
1133 fn message_attribute_forwards_normalized_path() {
1134 let cfg = Config::new().message_attribute(".", "#[serde(default)]");
1135 assert_eq!(
1136 cfg.codegen_config.message_attributes,
1137 vec![(".".to_string(), "#[serde(default)]".to_string())]
1138 );
1139 }
1140
1141 #[test]
1142 fn enum_attribute_forwards_normalized_path() {
1143 let cfg = Config::new().enum_attribute("my.pkg.", "#[derive(strum::EnumIter)]");
1144 assert_eq!(
1145 cfg.codegen_config.enum_attributes,
1146 vec![(
1147 ".my.pkg".to_string(),
1148 "#[derive(strum::EnumIter)]".to_string(),
1149 )]
1150 );
1151 // Other attribute lists must remain untouched.
1152 assert!(cfg.codegen_config.type_attributes.is_empty());
1153 assert!(cfg.codegen_config.message_attributes.is_empty());
1154 assert!(cfg.codegen_config.field_attributes.is_empty());
1155 }
1156
1157 #[test]
1158 fn attribute_calls_accumulate_in_insertion_order() {
1159 let cfg = Config::new()
1160 .type_attribute(".", "#[derive(A)]")
1161 .type_attribute(".pkg.M", "#[derive(B)]")
1162 .type_attribute(".", "#[derive(C)]");
1163 let paths: Vec<_> = cfg
1164 .codegen_config
1165 .type_attributes
1166 .iter()
1167 .map(|(_, a)| a.as_str())
1168 .collect();
1169 assert_eq!(paths, vec!["#[derive(A)]", "#[derive(B)]", "#[derive(C)]"]);
1170 }
1171}