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
35#[doc(inline)]
36pub use buffa_codegen::CodeGenConfig;
37#[doc(inline)]
38pub use buffa_codegen::ReflectMode;
39#[doc(inline)]
40pub use buffa_codegen::StringRepr;
41
42/// How to produce a `FileDescriptorSet` from `.proto` files.
43#[derive(Debug, Clone, Default)]
44enum DescriptorSource {
45 /// Invoke `protoc` (default). Requires `protoc` on PATH or `PROTOC` env var.
46 #[default]
47 Protoc,
48 /// Invoke `buf build --as-file-descriptor-set`. Requires `buf` on PATH.
49 Buf,
50 /// Read a pre-built `FileDescriptorSet` from a file.
51 Precompiled(PathBuf),
52}
53
54/// Builder for configuring and running protobuf compilation.
55pub struct Config {
56 files: Vec<PathBuf>,
57 includes: Vec<PathBuf>,
58 out_dir: Option<PathBuf>,
59 codegen_config: CodeGenConfig,
60 descriptor_source: DescriptorSource,
61 /// If set, generate a module-tree include file with this name in the
62 /// output directory. Users can then `include!` this single file instead
63 /// of manually setting up `pub mod` nesting.
64 include_file: Option<String>,
65}
66
67impl Config {
68 /// Create a new configuration with defaults.
69 pub fn new() -> Self {
70 Self {
71 files: Vec::new(),
72 includes: Vec::new(),
73 out_dir: None,
74 codegen_config: CodeGenConfig::default(),
75 descriptor_source: DescriptorSource::default(),
76 include_file: None,
77 }
78 }
79
80 /// Add `.proto` files to compile.
81 #[must_use]
82 pub fn files(mut self, files: &[impl AsRef<Path>]) -> Self {
83 self.files
84 .extend(files.iter().map(|f| f.as_ref().to_path_buf()));
85 self
86 }
87
88 /// Add include directories for protoc to search for imports.
89 #[must_use]
90 pub fn includes(mut self, includes: &[impl AsRef<Path>]) -> Self {
91 self.includes
92 .extend(includes.iter().map(|i| i.as_ref().to_path_buf()));
93 self
94 }
95
96 /// Set the output directory for generated files.
97 /// Defaults to `$OUT_DIR` if not set.
98 #[must_use]
99 pub fn out_dir(mut self, dir: impl Into<PathBuf>) -> Self {
100 self.out_dir = Some(dir.into());
101 self
102 }
103
104 /// Enable or disable view type generation (default: true).
105 #[must_use]
106 pub fn generate_views(mut self, enabled: bool) -> Self {
107 self.codegen_config.generate_views = enabled;
108 self
109 }
110
111 /// Enable or disable serde JSON generation (default: false).
112 ///
113 /// When enabled:
114 /// - Generated message structs get `Serialize`/`Deserialize` derives.
115 /// - Generated enum types get `Serialize`/`Deserialize` derives.
116 /// - Generated view types (when `generate_views` is also enabled) get a
117 /// manual `impl Serialize` for zero-copy JSON serialization, so
118 /// `serde_json::to_string(&view)` works directly:
119 ///
120 /// ```ignore
121 /// let view = MyMsgView::decode_view(&bytes)?;
122 /// let json = serde_json::to_string(&view)?;
123 /// ```
124 ///
125 /// The downstream crate must depend on `serde` and enable the `buffa/json`
126 /// feature for the runtime helpers. When views are enabled, the crate must
127 /// also enable `buffa-types/json` so the well-known type views implement
128 /// `Serialize`; without it, references to e.g. `TimestampView<'_>` in the
129 /// generated `Serialize` impl will fail with
130 /// `the trait bound 'TimestampView<'_>: Serialize' is not satisfied`.
131 ///
132 /// **Limitations of the view `Serialize` impl:**
133 /// - Extension fields are not included in view JSON output; serialize the
134 /// owned form (`view.to_owned_message()`) to include extensions.
135 /// - The impl uses `serialize_map(None)` (unknown length) because the
136 /// number of emitted fields depends on default-omission rules. Most
137 /// self-describing serializers (notably `serde_json`) accept this, but
138 /// length-prefixed formats (e.g. `bincode`, `postcard`) will return a
139 /// runtime error. The owned types' derived `Serialize` does not have this
140 /// restriction.
141 #[must_use]
142 pub fn generate_json(mut self, enabled: bool) -> Self {
143 self.codegen_config.generate_json = enabled;
144 self
145 }
146
147 /// Enable or disable `impl buffa::text::TextFormat` on generated message
148 /// structs (default: false).
149 ///
150 /// When enabled, the downstream crate must enable the `buffa/text`
151 /// feature for the runtime textproto encoder/decoder.
152 #[must_use]
153 pub fn generate_text(mut self, enabled: bool) -> Self {
154 self.codegen_config.generate_text = enabled;
155 self
156 }
157
158 /// Enable or disable `#[derive(arbitrary::Arbitrary)]` on generated
159 /// types (default: false).
160 ///
161 /// The derive is gated behind `#[cfg_attr(feature = "arbitrary", ...)]`
162 /// so the downstream crate compiles with or without the feature enabled.
163 ///
164 /// Your crate's Cargo feature **must be named exactly `"arbitrary"`** —
165 /// the generated `cfg_attr` uses that literal string and cannot be
166 /// customised — and it must forward to `buffa/arbitrary`:
167 ///
168 /// ```toml
169 /// [features]
170 /// arbitrary = ["dep:arbitrary", "buffa/arbitrary"]
171 /// ```
172 ///
173 /// Forgetting `"buffa/arbitrary"` produces a confusing
174 /// `cannot find function 'arbitrary_bytes' in module '__private'` error
175 /// in generated code when [`use_bytes_type`](Self::use_bytes_type) or
176 /// [`use_bytes_type_in`](Self::use_bytes_type_in) is also enabled,
177 /// because the helper that backs `#[arbitrary(with = ...)]` for
178 /// `bytes::Bytes` fields lives in `buffa` under that feature gate.
179 #[must_use]
180 pub fn generate_arbitrary(mut self, enabled: bool) -> Self {
181 self.codegen_config.generate_arbitrary = enabled;
182 self
183 }
184
185 /// Wrap generated `impl`s in `#[cfg(feature = "...")]` instead of
186 /// emitting them unconditionally (default: false).
187 ///
188 /// When enabled, the impls controlled by [`generate_json`],
189 /// [`generate_views`], and [`generate_text`] are wrapped in
190 /// `#[cfg(feature = "json" | "views" | "text")]` (or
191 /// `#[cfg_attr(feature = ..., ...)]` for derives and field attributes)
192 /// rather than emitted unconditionally. The crate consuming the
193 /// generated code must define matching Cargo features that enable the
194 /// corresponding runtime support:
195 ///
196 /// ```toml
197 /// [features]
198 /// json = ["buffa/json", "dep:serde", "dep:serde_json"]
199 /// views = []
200 /// text = ["buffa/text"]
201 /// ```
202 ///
203 /// The `generate_*` flags still control *whether* an impl kind is
204 /// emitted at all — this flag only controls whether it is `cfg`-gated.
205 /// `generate_arbitrary` is always `cfg_attr`-gated on
206 /// `feature = "arbitrary"` regardless of this flag, because `arbitrary`
207 /// is an optional dependency by design.
208 ///
209 /// Reach for this when generated code is the **public interface of a
210 /// library crate** consumed by downstream projects with different
211 /// feature needs — exactly the shape of `buffa-descriptor` and
212 /// `buffa-types`, which ship every impl while letting the codegen
213 /// toolchain (`buffa-codegen`/`buffa-build`/`protoc-gen-buffa`) depend
214 /// on them with `default-features = false` and stay free of
215 /// `serde`/`serde_json`/`base64`. Most consumers of `buffa-build` are
216 /// **not** in this position: a `build.rs` that decides at build-script
217 /// time whether to generate JSON wants `impl Serialize` to just exist.
218 /// Default `false`.
219 ///
220 /// [`generate_json`]: Self::generate_json
221 /// [`generate_views`]: Self::generate_views
222 /// [`generate_text`]: Self::generate_text
223 #[must_use]
224 pub fn gate_impls_on_crate_features(mut self, enabled: bool) -> Self {
225 self.codegen_config.gate_impls_on_crate_features = enabled;
226 self
227 }
228
229 /// Gate only the reflection impls behind a `reflect` crate feature, without
230 /// gating json/views/text (unlike
231 /// [`gate_impls_on_crate_features`](Self::gate_impls_on_crate_features),
232 /// which gates them together).
233 ///
234 /// For crates that ship views/text unconditionally but want the
235 /// `buffa-descriptor`-dependent (and `std`-requiring) reflection surface to
236 /// be opt-in. `buffa-types` is the motivating case.
237 ///
238 /// **Experimental and `#[doc(hidden)]`**, paired with
239 /// [`generate_reflection_vtable`](Self::generate_reflection_vtable) until the
240 /// public `ReflectMode` selector lands.
241 #[doc(hidden)]
242 #[must_use]
243 pub fn gate_reflect_on_crate_feature(mut self, enabled: bool) -> Self {
244 self.codegen_config.gate_reflect_on_crate_feature = enabled;
245 self
246 }
247
248 /// Enable or disable `with_*` builder-style setter methods for
249 /// explicit-presence fields (default: true).
250 ///
251 /// Each explicit-presence scalar, bytes, or enum field gets a
252 /// `pub fn with_<name>(mut self, value: T) -> Self` method that wraps the
253 /// value in `Some(...)` and returns `self`, enabling chained construction
254 /// without the `Some(...)` boilerplate:
255 ///
256 /// ```ignore
257 /// let req = MyRequest::default()
258 /// .with_name("alice")
259 /// .with_timeout_ms(30_000);
260 /// ```
261 ///
262 /// String, bytes, and enum setters take `impl Into<T>` (so `&str`,
263 /// `b"..."` literals, and bare enum variants work directly); other
264 /// scalars take `T` to keep integer-literal inference unambiguous.
265 ///
266 /// Setters are pure inherent methods with no runtime dependency — they
267 /// don't interact with the `json`/`views`/`text` feature gates. Disable
268 /// only if you want to keep generated code minimal or have a competing
269 /// `with_*` convention in your own crate.
270 #[must_use]
271 pub fn generate_with_setters(mut self, enabled: bool) -> Self {
272 self.codegen_config.generate_with_setters = enabled;
273 self
274 }
275
276 /// Enable reflection on generated types (default: off).
277 ///
278 /// `generate_reflection(true)` selects [`ReflectMode::VTable`] — the fast
279 /// path: `foo.reflect()` borrows `foo` directly (no encode/decode
280 /// round-trip), and owned and view types implement `ReflectMessage`. For
281 /// the smaller bridge implementation (`reflect()` round-trips through a
282 /// [`DynamicMessage`]), use [`reflect_mode(ReflectMode::Bridge)`](Self::reflect_mode)
283 /// instead. `generate_reflection(false)` is [`ReflectMode::Off`].
284 ///
285 /// Either mode embeds a lazily-built [`DescriptorPool`] (as
286 /// `FileDescriptorSet` bytes) reachable as
287 /// `your_crate::your_pkg::descriptor_pool()`.
288 ///
289 /// # Cargo.toml setup
290 ///
291 /// The consuming crate must depend on `buffa-descriptor` with the
292 /// `reflect` feature and on `std`:
293 ///
294 /// ```toml
295 /// [dependencies]
296 /// buffa = { version = "0.7", features = ["std"] }
297 /// buffa-descriptor = { version = "0.7", features = ["reflect", "std"] }
298 /// ```
299 ///
300 /// When [`gate_impls_on_crate_features`](Self::gate_impls_on_crate_features)
301 /// is also on, the impls are wrapped in `#[cfg(feature = "reflect")]`,
302 /// so the consuming crate must declare a forwarding feature:
303 ///
304 /// ```toml
305 /// [features]
306 /// reflect = ["buffa-descriptor/reflect"]
307 /// ```
308 ///
309 /// **Without the feature declared, the generated `Reflectable` impls
310 /// silently disappear** — `cfg(feature = "reflect")` is permanently
311 /// false in a crate that doesn't declare it. The first call to
312 /// `.reflect()` fails to compile with "trait `Reflectable` not
313 /// implemented", which is a misleading diagnostic. Most consumers
314 /// should leave `gate_impls_on_crate_features` off.
315 ///
316 /// Reflecting message-typed fields also requires every crate that field
317 /// types resolve to via an extern path — notably `buffa-types` for
318 /// well-known types — to enable its own reflection feature; see
319 /// [`reflect_mode`](Self::reflect_mode) ("Extern-path types") for the
320 /// `Cargo.toml` requirement and mixed-mode behavior.
321 ///
322 /// # Performance
323 ///
324 /// In the default vtable mode, `reflect()` borrows `self` — no round-trip,
325 /// no allocation; reflective accessors read fields in place. (Bridge mode
326 /// instead pays one encode/decode round-trip plus a heap allocation per
327 /// call.) Either way the first call pays a one-time pool build cost.
328 ///
329 /// # Build time and binary size
330 ///
331 /// Each generated package embeds its own copy of the full
332 /// `FileDescriptorSet` (transitive closure). For a single-package
333 /// crate this is one copy. For a multi-package codegen run the bytes
334 /// duplicate per package — measurable for large proto trees. The
335 /// serialization happens once per `compile()` call (not per package),
336 /// so build-time CPU does not scale with package count. Vtable mode also
337 /// emits an `impl ReflectMessage` per type, so it produces more code than
338 /// bridge mode.
339 ///
340 /// [`ReflectCow`]: https://docs.rs/buffa-descriptor/latest/buffa_descriptor/reflect/enum.ReflectCow.html
341 /// [`DynamicMessage`]: https://docs.rs/buffa-descriptor/latest/buffa_descriptor/reflect/struct.DynamicMessage.html
342 /// [`DescriptorPool`]: https://docs.rs/buffa-descriptor/latest/buffa_descriptor/struct.DescriptorPool.html
343 #[must_use]
344 pub fn generate_reflection(mut self, enabled: bool) -> Self {
345 // The simple on/off knob selects the fast vtable path; Bridge is opt-in
346 // via `reflect_mode`.
347 let mode = if enabled {
348 ReflectMode::VTable
349 } else {
350 ReflectMode::Off
351 };
352 mode.apply(&mut self.codegen_config);
353 self
354 }
355
356 /// Select the reflection mode (the fuller form of
357 /// [`generate_reflection`](Self::generate_reflection)).
358 ///
359 /// - [`ReflectMode::Off`] — no reflection (the default); equivalent to
360 /// `generate_reflection(false)`.
361 /// - [`ReflectMode::Bridge`] — `reflect()` round-trips through
362 /// `DynamicMessage`; smaller generated code, slower reflective access.
363 /// - [`ReflectMode::VTable`] — `impl ReflectMessage` on owned and view
364 /// types, and `reflect()` borrows `self` with no round-trip; equivalent
365 /// to `generate_reflection(true)`. Does not require view generation —
366 /// with views off, only the owned impls are emitted.
367 ///
368 /// All non-`Off` modes require the consuming crate to depend on
369 /// `buffa-descriptor` with its `reflect` feature and on `std`. The call
370 /// site (`foo.reflect().get(fd)`) is identical across modes.
371 ///
372 /// # Extern-path types
373 ///
374 /// Reflection on a message reaches into its message-typed fields, so
375 /// every crate that field types resolve to via an extern path must have
376 /// its own reflection enabled. In particular, well-known types resolve
377 /// to `buffa-types` by default, and its impls are behind a cargo
378 /// feature: depend on `buffa-types = { ..., features = ["reflect"] }`
379 /// or the build fails with unsatisfied `Reflectable` /
380 /// `ReflectMessage` bounds on the WKT.
381 ///
382 /// # Mixed modes
383 ///
384 /// A vtable-mode message may embed owned message types generated in
385 /// bridge mode (e.g. a dependency crate that chose the smaller output):
386 /// reflective access degrades to an owned `DynamicMessage` snapshot at
387 /// that boundary instead of failing. For a bridge-grade `repeated` or
388 /// `map` field the snapshot is taken per element on every access, so
389 /// reflecting a large mixed-mode collection scales the encode/decode
390 /// cost by the element count. The *view* reflection surface cannot
391 /// degrade — every view type embedded in a vtable-mode view must itself
392 /// be vtable-grade, and a bridge-grade view field is a compile error.
393 #[must_use]
394 pub fn reflect_mode(mut self, mode: ReflectMode) -> Self {
395 mode.apply(&mut self.codegen_config);
396 self
397 }
398
399 /// Enable or disable idiomatic `UpperCamelCase` enum aliases (matches the
400 /// [`CodeGenConfig`] default, currently on).
401 ///
402 /// Protobuf enum values are `SHOUTY_SNAKE_CASE` and stay the definitive Rust
403 /// variants. When enabled, codegen additionally emits associated `const`s
404 /// with the enum-name prefix stripped and the name converted to
405 /// `UpperCamelCase` (`RULE_LEVEL_HIGH` → `RuleLevel::High`), purely
406 /// additively — existing references and `Debug` output are unchanged.
407 ///
408 /// Aliases are suppressed per enum (with a build warning and a doc note) if
409 /// any two values would collide after conversion, so a match is never forced
410 /// to mix conventions. See [`CodeGenConfig::idiomatic_enum_aliases`].
411 #[must_use]
412 pub fn idiomatic_enum_aliases(mut self, enabled: bool) -> Self {
413 self.codegen_config.idiomatic_enum_aliases = enabled;
414 self
415 }
416
417 /// Enable or disable unknown field preservation (default: true).
418 ///
419 /// When enabled (the default), unrecognized fields encountered during
420 /// decode are stored and re-emitted on encode — essential for proxy /
421 /// middleware services and round-trip fidelity across schema versions.
422 ///
423 /// **Disabling is primarily a memory optimization** (24 bytes/message for
424 /// the `UnknownFields` Vec header), not a throughput one. When no unknown
425 /// fields appear on the wire — the common case for schema-aligned
426 /// services — decode and encode costs are effectively identical in
427 /// either mode. Consider disabling for embedded / `no_std` targets or
428 /// large in-memory collections of small messages.
429 #[must_use]
430 pub fn preserve_unknown_fields(mut self, enabled: bool) -> Self {
431 self.codegen_config.preserve_unknown_fields = enabled;
432 self
433 }
434
435 /// Honor `features.utf8_validation = NONE` by emitting `Vec<u8>` / `&[u8]`
436 /// for such string fields instead of `String` / `&str` (default: false).
437 ///
438 /// When disabled (the default), all string fields map to `String` and
439 /// UTF-8 is validated on decode — stricter than proto2 requires, but
440 /// ergonomic and safe.
441 ///
442 /// When enabled, string fields with `utf8_validation = NONE` become
443 /// `Vec<u8>` / `&[u8]`. Decode skips validation; the caller chooses
444 /// whether to `std::str::from_utf8` (checked) or `from_utf8_unchecked`
445 /// (trusted-input fast path). This is the only sound Rust mapping when
446 /// strings may actually contain non-UTF-8 bytes.
447 ///
448 /// **Note for proto2 users**: proto2's default is `utf8_validation = NONE`,
449 /// so enabling this turns ALL proto2 string fields into `Vec<u8>`. Use
450 /// only for new code or when profiling identifies UTF-8 validation as a
451 /// bottleneck (it can be 10%+ of decode CPU for string-heavy messages).
452 ///
453 /// **JSON note**: fields normalized to bytes serialize as base64 in JSON
454 /// (the proto3 JSON encoding for `bytes`). Keep strict mapping disabled
455 /// for fields that need JSON string interop with other implementations.
456 ///
457 /// **Interaction with [`use_bytes_type`]**: when both are enabled,
458 /// `map<bytes, bytes>` values stay `Vec<u8>` (the bytes-keyed JSON helper
459 /// is concrete `HashMap<Vec<u8>, Vec<u8>>`). All other `bytes` shapes —
460 /// singular / optional / repeated / oneof / `map<non-bytes, bytes>` —
461 /// still become `bytes::Bytes`. The asymmetry is documented; if you hit
462 /// it, see issue #76.
463 ///
464 /// [`use_bytes_type`]: Self::use_bytes_type
465 #[must_use]
466 pub fn strict_utf8_mapping(mut self, enabled: bool) -> Self {
467 self.codegen_config.strict_utf8_mapping = enabled;
468 self
469 }
470
471 /// Permit `option message_set_wire_format = true` on input messages.
472 ///
473 /// MessageSet is a legacy Google-internal wire format. Default: `false`
474 /// (such messages produce a codegen error). Set to `true` only when
475 /// compiling protos that interoperate with old Google-internal services.
476 #[must_use]
477 pub fn allow_message_set(mut self, enabled: bool) -> Self {
478 self.codegen_config.allow_message_set = enabled;
479 self
480 }
481
482 /// Declare an external type path mapping.
483 ///
484 /// The matched types reference the specified Rust path instead of being
485 /// generated. This allows shared proto packages to be compiled once in a
486 /// dedicated crate and referenced from others.
487 ///
488 /// `proto_path` is a fully-qualified protobuf path — either a **package**
489 /// (`".my.common"`, mapping every type under it to a Rust module root) or a
490 /// single **type FQN** (`".google.protobuf.Timestamp"`, mapping just that
491 /// type, the prost/tonic idiom). The leading dot is optional and is added
492 /// automatically. As in prost, the most specific entry wins: an exact type
493 /// FQN beats a covering package prefix, which in turn beats a shorter
494 /// prefix.
495 ///
496 /// `rust_path` is where the type(s) are accessible — a module root for a
497 /// package mapping (e.g. `"::common_protos"`) or a full type path for a
498 /// per-type mapping (e.g. `"::pbjson_types::Timestamp"`). It must be an
499 /// absolute path (starting with `::` or `crate::`); any other value is
500 /// emitted into the generated code verbatim and will fail to resolve there.
501 ///
502 /// **Nested types** inherit an enclosing message's per-type override:
503 /// mapping `.my.pkg.Outer` to `::ext::Outer` resolves `.my.pkg.Outer.Inner`
504 /// to `::ext::outer::Inner` — the override's parent module plus buffa's
505 /// usual `snake_case(MessageName)` nested-types module (snake case of the
506 /// *proto* message name, regardless of the override's final segment). This
507 /// matches the layout of another buffa-generated crate; for a target crate
508 /// laid out differently, add explicit per-type entries for the nested types
509 /// as well.
510 ///
511 /// # Limitations
512 ///
513 /// An extern type that is referenced by a generated **view** must map to
514 /// another buffa-generated crate — the view path is composed as
515 /// `<rust_path_root>::__buffa::view::…`, which a non-buffa crate (e.g.
516 /// `pbjson_types`) does not provide. Map per-type to a buffa crate, or
517 /// disable views ([`generate_views(false)`](Self::generate_views)), for
518 /// such types.
519 ///
520 /// A misconfigured mapping (a typo'd FQN target, a non-absolute
521 /// `rust_path`, or a view-referenced type mapped to a non-buffa crate) is
522 /// not diagnosed at generation time; it surfaces as an unresolved-path
523 /// error when the generated code is compiled.
524 ///
525 /// # Example
526 ///
527 /// ```rust,ignore
528 /// buffa_build::Config::new()
529 /// // Whole-package mapping.
530 /// .extern_path(".my.common", "::common_protos")
531 /// // Per-type mapping (issue #111) — overrides the package prefix for
532 /// // just this type.
533 /// .extern_path(".google.protobuf.Timestamp", "::common_protos::well_known::Timestamp")
534 /// .files(&["proto/my_service.proto"])
535 /// .includes(&["proto/"])
536 /// .compile()
537 /// .unwrap();
538 /// ```
539 #[must_use]
540 pub fn extern_path(
541 mut self,
542 proto_path: impl Into<String>,
543 rust_path: impl Into<String>,
544 ) -> Self {
545 let mut proto_path = proto_path.into();
546 // Normalize: ensure the proto path is fully-qualified (leading dot).
547 // Accept both ".my.package" and "my.package" for convenience.
548 if !proto_path.starts_with('.') {
549 proto_path.insert(0, '.');
550 }
551 self.codegen_config
552 .extern_paths
553 .push((proto_path, rust_path.into()));
554 self
555 }
556
557 /// Configure `bytes` fields to use `bytes::Bytes` instead of `Vec<u8>`.
558 ///
559 /// Each path is a fully-qualified proto path prefix. Use `"."` to apply
560 /// to all bytes fields, or specify individual field paths like
561 /// `".my.pkg.MyMessage.data"`.
562 ///
563 /// Applies uniformly to singular, optional, repeated, oneof, **and
564 /// `map<K, bytes>`** values — the map case lets `view → owned`
565 /// conversion participate in the `to_owned_from_source` zero-copy
566 /// `slice_ref` path. One carve-out: an effective `map<bytes, bytes>` keeps
567 /// `Vec<u8>` values (the JSON helper for that combination is concrete
568 /// `HashMap<Vec<u8>, Vec<u8>>`); every other shape becomes `Bytes`. A
569 /// `bytes` map key is only reachable when [`strict_utf8_mapping`] is enabled
570 /// *and* the `map<string, bytes>` field carries
571 /// `[features.utf8_validation = NONE]` on its key, which normalizes the
572 /// string key to `bytes` — `strict_utf8_mapping` alone does not trigger it.
573 ///
574 /// [`strict_utf8_mapping`]: Self::strict_utf8_mapping
575 ///
576 /// # Example
577 ///
578 /// ```rust,ignore
579 /// buffa_build::Config::new()
580 /// .use_bytes_type_in(&["."]) // all bytes fields use Bytes
581 /// .files(&["proto/my_service.proto"])
582 /// .includes(&["proto/"])
583 /// .compile()
584 /// .unwrap();
585 /// ```
586 #[must_use]
587 pub fn use_bytes_type_in(mut self, paths: &[impl AsRef<str>]) -> Self {
588 self.codegen_config
589 .bytes_fields
590 .extend(paths.iter().map(|p| p.as_ref().to_string()));
591 self
592 }
593
594 /// Use `bytes::Bytes` for all `bytes` fields in all messages.
595 ///
596 /// This is a convenience for `.use_bytes_type_in(&["."])`. Use
597 /// [`use_bytes_type_in`] with specific proto paths if you only want `Bytes`
598 /// for certain fields. See that method for the path-matching semantics, the
599 /// `map<K, bytes>` rule, and the `map<bytes, bytes>` carve-out under
600 /// [`strict_utf8_mapping`].
601 ///
602 /// [`use_bytes_type_in`]: Self::use_bytes_type_in
603 /// [`strict_utf8_mapping`]: Self::strict_utf8_mapping
604 #[must_use]
605 pub fn use_bytes_type(mut self) -> Self {
606 self.codegen_config.bytes_fields.push(".".to_string());
607 self
608 }
609
610 /// Store the matching message-typed oneof variants inline instead of
611 /// wrapping them in `Box<T>`.
612 ///
613 /// By default every message/group oneof variant is boxed so that recursive
614 /// types compile. For non-recursive variants the `Box` is pure overhead (an
615 /// allocation per construction); this opts the matching variants out.
616 /// This affects the owned message enum only — view oneof variants remain
617 /// boxed.
618 ///
619 /// Each path is a fully-qualified proto variant path prefix, e.g.
620 /// `".my.pkg.MyMessage.body.small"` for one variant or `".my.pkg"` for a
621 /// package (same matching as [`use_bytes_type_in`](Self::use_bytes_type_in)).
622 /// A leading dot is added if missing, mirroring
623 /// [`extern_path`](Self::extern_path).
624 ///
625 /// Recursive variants cannot be stored inline (the type would be
626 /// unsized). A rule that names a recursive variant *exactly* is rejected
627 /// at codegen time; a broader prefix rule silently keeps recursive
628 /// variants boxed and inlines the rest. For example, with
629 /// `unbox_oneof_in(&[".my.pkg.Node"])`, a self-referential
630 /// `Node.kind.child` variant stays boxed while `Node`'s other message
631 /// variants become inline.
632 #[must_use]
633 pub fn unbox_oneof_in(mut self, paths: &[impl AsRef<str>]) -> Self {
634 self.codegen_config
635 .unboxed_oneof_fields
636 .extend(paths.iter().map(|p| {
637 let p = p.as_ref();
638 // Normalize to the leading-dot form: matching and the
639 // exact-path recursion error both depend on it.
640 if p.starts_with('.') {
641 p.to_string()
642 } else {
643 format!(".{p}")
644 }
645 }));
646 self
647 }
648
649 /// Store every non-recursive message-typed oneof variant inline instead of
650 /// boxing it. Convenience for `.unbox_oneof_in(&["."])`; recursive
651 /// variants stay boxed.
652 #[must_use]
653 pub fn unbox_oneof(mut self) -> Self {
654 self.codegen_config
655 .unboxed_oneof_fields
656 .push(".".to_string());
657 self
658 }
659
660 /// Map `string` fields to a [`StringRepr`] other than `String` for the
661 /// given proto path prefixes. The string counterpart to
662 /// [`use_bytes_type_in`](Self::use_bytes_type_in).
663 ///
664 /// Each path is a fully-qualified proto path prefix (e.g.
665 /// `".my.pkg.MyMessage.name"` for one field, `".my.pkg"` for a package).
666 ///
667 /// Rules accumulate and the **last** matching rule wins. Order therefore
668 /// matters: call [`string_type`](Self::string_type) (the broad default)
669 /// *first*, then `string_type_in` for narrower overrides — a broad rule
670 /// added after a specific one will shadow it.
671 ///
672 /// The downstream crate must enable the selected type's `buffa` feature
673 /// (`smol_str`, `ecow`, or `compact_str`); otherwise the generated
674 /// `::buffa::<crate>::<Type>` references fail to resolve.
675 ///
676 /// Only the owned Rust type changes: the wire format is unchanged, view
677 /// types still borrow `&str`, and `map<_, string>` keys and values stay
678 /// `String`.
679 ///
680 /// # Example
681 ///
682 /// ```rust,ignore
683 /// use buffa_build::StringRepr;
684 /// buffa_build::Config::new()
685 /// .string_type(StringRepr::SmolStr) // broad default first
686 /// .string_type_in(StringRepr::CompactString, &[".my.pkg.Msg.body"]) // narrow override
687 /// .files(&["proto/my_service.proto"])
688 /// .includes(&["proto/"])
689 /// .compile()
690 /// .unwrap();
691 /// ```
692 #[must_use]
693 pub fn string_type_in(mut self, repr: StringRepr, paths: &[impl AsRef<str>]) -> Self {
694 self.codegen_config
695 .string_fields
696 .extend(paths.iter().map(|p| (p.as_ref().to_string(), repr)));
697 self
698 }
699
700 /// Map every `string` field in all messages to the given [`StringRepr`].
701 ///
702 /// Convenience for `.string_type_in(repr, &["."])`. Call this *before* any
703 /// [`string_type_in`](Self::string_type_in) overrides, since the last
704 /// matching rule wins (a `"."` rule added later shadows earlier specific
705 /// rules). `map<_, string>` keys and values stay `String`, and the
706 /// downstream crate must enable the selected type's `buffa` feature.
707 #[must_use]
708 pub fn string_type(mut self, repr: StringRepr) -> Self {
709 self.codegen_config
710 .string_fields
711 .push((".".to_string(), repr));
712 self
713 }
714
715 /// Add a custom attribute to generated types (messages and enums)
716 /// matching a proto path prefix.
717 ///
718 /// `path` is a fully-qualified proto path prefix: `"."` applies to all
719 /// types, `".my.pkg"` to types in that package, `".my.pkg.MyMessage"`
720 /// to a specific type. A leading `.` is auto-prepended if omitted; a
721 /// trailing `.` is trimmed. Prefix matching respects proto-segment
722 /// boundaries, so `".my.pk"` does not match `".my.pkg.Msg"`.
723 ///
724 /// `attribute` is a raw Rust attribute string
725 /// (e.g., `"#[derive(serde::Serialize)]"`). A malformed attribute
726 /// produces [`CodeGenError::InvalidCustomAttribute`](buffa_codegen::CodeGenError)
727 /// at compile time rather than being silently dropped.
728 ///
729 /// Multiple calls accumulate in insertion order — all matching attributes
730 /// are emitted, and ordering is preserved in generated code.
731 ///
732 /// Also applies to generated oneof enums when `path` matches
733 /// `".pkg.Msg.my_oneof"` (the oneof's fully-qualified path).
734 ///
735 /// # Pitfalls
736 ///
737 /// buffa already emits `#[derive(Clone, PartialEq)]` on messages and
738 /// `#[derive(Clone, PartialEq, Debug)]` on oneofs (oneofs with a
739 /// `[debug_redact = true]` variant get a generated `Debug` impl instead
740 /// of the `Debug` derive); adding a duplicate derive via
741 /// `type_attribute(".", "#[derive(Clone)]")` produces a compile error in
742 /// the generated code.
743 ///
744 /// # Example
745 ///
746 /// ```rust,ignore
747 /// buffa_build::Config::new()
748 /// .type_attribute(".", "#[derive(serde::Serialize)]")
749 /// .type_attribute(".my.pkg.MyEnum", "#[derive(strum::EnumIter)]")
750 /// .files(&["proto/my_service.proto"])
751 /// .includes(&["proto/"])
752 /// .compile()
753 /// .unwrap();
754 /// ```
755 #[must_use]
756 pub fn type_attribute(mut self, path: impl Into<String>, attribute: impl Into<String>) -> Self {
757 self.codegen_config
758 .type_attributes
759 .push((normalize_attr_path(path.into()), attribute.into()));
760 self
761 }
762
763 /// Add a custom attribute to generated struct fields matching a proto
764 /// path prefix.
765 ///
766 /// `path` is a fully-qualified proto field path (e.g.,
767 /// `".my.pkg.MyMessage.my_field"`). `"."` applies to all fields. A
768 /// leading `.` is auto-prepended if omitted; a trailing `.` is trimmed.
769 /// Prefix matching respects proto-segment boundaries.
770 ///
771 /// Also applies to oneof variants when `path` matches
772 /// `".pkg.Msg.my_oneof.variant_name"`.
773 ///
774 /// # Example
775 ///
776 /// ```rust,ignore
777 /// buffa_build::Config::new()
778 /// .field_attribute(".my.pkg.MyMessage.secret_key", "#[serde(skip)]")
779 /// .files(&["proto/my_service.proto"])
780 /// .includes(&["proto/"])
781 /// .compile()
782 /// .unwrap();
783 /// ```
784 #[must_use]
785 pub fn field_attribute(
786 mut self,
787 path: impl Into<String>,
788 attribute: impl Into<String>,
789 ) -> Self {
790 self.codegen_config
791 .field_attributes
792 .push((normalize_attr_path(path.into()), attribute.into()));
793 self
794 }
795
796 /// Add a custom attribute to generated message structs only (not enums,
797 /// not oneof enums) matching a proto path prefix.
798 ///
799 /// Same path-matching semantics as [`type_attribute`](Self::type_attribute) —
800 /// leading `.` auto-prepended, trailing `.` trimmed, proto-segment-aware
801 /// prefix matching, accumulation in insertion order. A malformed attribute
802 /// produces a compile-time error. Useful for struct-only attributes like
803 /// `#[serde(default)]`.
804 ///
805 /// # Example
806 ///
807 /// ```rust,ignore
808 /// buffa_build::Config::new()
809 /// .message_attribute(".", "#[serde(default)]")
810 /// .files(&["proto/my_service.proto"])
811 /// .includes(&["proto/"])
812 /// .compile()
813 /// .unwrap();
814 /// ```
815 #[must_use]
816 pub fn message_attribute(
817 mut self,
818 path: impl Into<String>,
819 attribute: impl Into<String>,
820 ) -> Self {
821 self.codegen_config
822 .message_attributes
823 .push((normalize_attr_path(path.into()), attribute.into()));
824 self
825 }
826
827 /// Add a custom attribute to generated enum types only (not message
828 /// structs, not oneof enums) matching a proto path prefix.
829 ///
830 /// Same path-matching semantics as [`type_attribute`](Self::type_attribute) —
831 /// leading `.` auto-prepended, trailing `.` trimmed, proto-segment-aware
832 /// prefix matching, accumulation in insertion order. A malformed attribute
833 /// produces a compile-time error. Useful when you want to inject an
834 /// attribute on every enum in a package without also matching the
835 /// (often more numerous) messages that share the path prefix — e.g.
836 /// `#[derive(strum::EnumIter)]`, which only makes sense on enums.
837 ///
838 /// # Example
839 ///
840 /// ```rust,ignore
841 /// buffa_build::Config::new()
842 /// .enum_attribute(".my.pkg", "#[derive(strum::EnumIter)]")
843 /// .files(&["proto/my_service.proto"])
844 /// .includes(&["proto/"])
845 /// .compile()
846 /// .unwrap();
847 /// ```
848 #[must_use]
849 pub fn enum_attribute(mut self, path: impl Into<String>, attribute: impl Into<String>) -> Self {
850 self.codegen_config
851 .enum_attributes
852 .push((normalize_attr_path(path.into()), attribute.into()));
853 self
854 }
855
856 /// Use `buf build` instead of `protoc` for descriptor generation.
857 ///
858 /// `buf` is often easier to install and keep current than `protoc`
859 /// (which many distros pin to old versions). This mode is intended for
860 /// the **single-crate case**: a `buf.yaml` at the crate root defining
861 /// the module layout.
862 ///
863 /// Requires `buf` on PATH and a `buf.yaml` at the crate root. The
864 /// [`includes()`](Self::includes) setting is ignored — buf resolves
865 /// imports via its own module configuration.
866 ///
867 /// Each path given to [`files()`](Self::files) must be **relative to its
868 /// owning module's directory** (the `path:` value inside `buf.yaml`), not
869 /// the crate root where `buf.yaml` itself lives. buf strips the module
870 /// path when producing `FileDescriptorProto.name`, so for
871 /// `modules: [{path: proto}]` and a file on disk at
872 /// `proto/api/v1/service.proto`, the descriptor name is
873 /// `api/v1/service.proto` — that is what `.files()` must contain.
874 /// Multiple modules in one `buf.yaml` work fine; buf enforces that
875 /// module-relative names are unique across the workspace.
876 ///
877 /// # Monorepo / multi-module setups
878 ///
879 /// For a workspace-root `buf.yaml` with many modules, this mode is a
880 /// poor fit. Prefer running `buf generate` with the `protoc-gen-buffa`
881 /// plugin and checking in the generated code, or use
882 /// [`descriptor_set()`](Self::descriptor_set) with the output of
883 /// `buf build --as-file-descriptor-set -o fds.binpb <module-path>`
884 /// run as a pre-build step.
885 ///
886 /// # Example
887 ///
888 /// ```rust,ignore
889 /// // buf.yaml (at crate root):
890 /// // version: v2
891 /// // modules:
892 /// // - path: proto
893 /// //
894 /// // build.rs:
895 /// buffa_build::Config::new()
896 /// .use_buf()
897 /// .files(&["api/v1/service.proto"]) // relative to module root
898 /// .compile()
899 /// .unwrap();
900 /// ```
901 #[must_use]
902 pub fn use_buf(mut self) -> Self {
903 self.descriptor_source = DescriptorSource::Buf;
904 self
905 }
906
907 /// Use a pre-compiled `FileDescriptorSet` binary file as input.
908 ///
909 /// Skips invoking `protoc` or `buf` entirely. The file must contain a
910 /// serialized `google.protobuf.FileDescriptorSet` (as produced by
911 /// `protoc --descriptor_set_out` or `buf build --as-file-descriptor-set`).
912 ///
913 /// When using this, `.files()` specifies which proto files in the
914 /// descriptor set to generate code for (matching by proto file name).
915 #[must_use]
916 pub fn descriptor_set(mut self, path: impl Into<PathBuf>) -> Self {
917 self.descriptor_source = DescriptorSource::Precompiled(path.into());
918 self
919 }
920
921 /// Generate a module-tree include file alongside the per-package `.rs`
922 /// files.
923 ///
924 /// The include file contains nested `pub mod` declarations with
925 /// `include!()` directives that assemble the generated code into a
926 /// module hierarchy matching the protobuf package structure. Users can
927 /// then include this single file instead of manually creating the
928 /// module tree.
929 ///
930 /// The form of the emitted `include!` directives depends on whether
931 /// [`out_dir`](Self::out_dir) was set:
932 ///
933 /// - **Default (`$OUT_DIR`)**: emits
934 /// `include!(concat!(env!("OUT_DIR"), "/foo.rs"))`, for use from
935 /// `build.rs` via `include!(concat!(env!("OUT_DIR"), "/<name>"))`.
936 /// - **Explicit `out_dir`**: emits sibling-relative `include!("foo.rs")`,
937 /// for checking the generated code into the source tree and referencing
938 /// it as a module (e.g. `mod gen;`).
939 ///
940 /// # Example — `build.rs` / `$OUT_DIR`
941 ///
942 /// ```rust,ignore
943 /// // build.rs
944 /// buffa_build::Config::new()
945 /// .files(&["proto/my_service.proto"])
946 /// .includes(&["proto/"])
947 /// .include_file("_include.rs")
948 /// .compile()
949 /// .unwrap();
950 ///
951 /// // lib.rs
952 /// include!(concat!(env!("OUT_DIR"), "/_include.rs"));
953 /// ```
954 ///
955 /// # Example — checked-in source
956 ///
957 /// ```rust,ignore
958 /// // codegen.rs (run manually, not from build.rs)
959 /// buffa_build::Config::new()
960 /// .files(&["proto/my_service.proto"])
961 /// .includes(&["proto/"])
962 /// .out_dir("src/gen")
963 /// .include_file("mod.rs")
964 /// .compile()
965 /// .unwrap();
966 ///
967 /// // lib.rs
968 /// mod gen;
969 /// ```
970 #[must_use]
971 pub fn include_file(mut self, name: impl Into<String>) -> Self {
972 self.include_file = Some(name.into());
973 self
974 }
975
976 /// Compile proto files and generate Rust source.
977 ///
978 /// # Errors
979 ///
980 /// Returns an error if:
981 /// - `OUT_DIR` is not set and no `out_dir` was configured
982 /// - `protoc` or `buf` cannot be found on `PATH` (when using those sources)
983 /// - the proto compiler exits with a non-zero status (syntax errors,
984 /// missing imports, etc.)
985 /// - a precompiled descriptor set file cannot be read
986 /// - the descriptor set bytes cannot be decoded as a `FileDescriptorSet`
987 /// - code generation fails (e.g. unsupported proto feature)
988 /// - the output directory cannot be created or written to
989 pub fn compile(self) -> Result<(), Box<dyn std::error::Error>> {
990 // When out_dir is explicitly set, the include file should use
991 // relative `include!("foo.rs")` paths (the index is a sibling of the
992 // generated files). When defaulted to $OUT_DIR, keep the
993 // `concat!(env!("OUT_DIR"), ...)` form so that
994 // `include!(concat!(env!("OUT_DIR"), "/_include.rs"))` from src/
995 // still resolves to absolute paths.
996 let relative_includes = self.out_dir.is_some();
997 let out_dir = self
998 .out_dir
999 .or_else(|| std::env::var("OUT_DIR").ok().map(PathBuf::from))
1000 .ok_or("OUT_DIR not set and no out_dir configured")?;
1001
1002 // Produce a FileDescriptorSet from the configured source.
1003 let descriptor_bytes = match &self.descriptor_source {
1004 DescriptorSource::Protoc => invoke_protoc(&self.files, &self.includes)?,
1005 DescriptorSource::Buf => invoke_buf()?,
1006 DescriptorSource::Precompiled(path) => std::fs::read(path).map_err(|e| {
1007 format!("failed to read descriptor set '{}': {}", path.display(), e)
1008 })?,
1009 };
1010 let fds = FileDescriptorSet::decode_from_slice(&descriptor_bytes)
1011 .map_err(|e| format!("failed to decode FileDescriptorSet: {}", e))?;
1012
1013 // Determine which files were explicitly requested.
1014 //
1015 // `FileDescriptorProto.name` contains the path relative to the proto
1016 // source root (protoc: `--proto_path`; buf: the module root). For
1017 // Precompiled and Buf mode, `.files()` are expected to already be
1018 // proto-relative names. For Protoc mode, strip the longest matching
1019 // include prefix.
1020 let files_to_generate: Vec<String> = if matches!(
1021 self.descriptor_source,
1022 DescriptorSource::Precompiled(_) | DescriptorSource::Buf
1023 ) {
1024 self.files
1025 .iter()
1026 .filter_map(|f| f.to_str().map(str::to_string))
1027 .collect()
1028 } else {
1029 self.files
1030 .iter()
1031 .map(|f| proto_relative_name(f, &self.includes))
1032 .filter(|s| !s.is_empty())
1033 .collect()
1034 };
1035
1036 // Generate Rust source. Per-proto content files plus a per-package
1037 // `.mod.rs` stitcher; only the stitchers need wiring into the
1038 // module tree (content files are reached via `include!` from
1039 // there).
1040 let (generated, warnings) = buffa_codegen::generate_with_diagnostics(
1041 &fds.file,
1042 &files_to_generate,
1043 &self.codegen_config,
1044 )?;
1045
1046 // Surface non-fatal codegen diagnostics as Cargo build warnings. This
1047 // runs inside the consumer's `build.rs`, so `cargo:warning=` is shown in
1048 // their normal `cargo build` output.
1049 for warning in warnings {
1050 println!("cargo:warning=buffa: {warning}");
1051 }
1052
1053 // Write output files; collect (name, package) for PackageMod entries.
1054 let mut output_entries: Vec<(String, String)> = Vec::new();
1055 for file in generated {
1056 let path = out_dir.join(&file.name);
1057 if let Some(parent) = path.parent() {
1058 std::fs::create_dir_all(parent)?;
1059 }
1060 write_if_changed(&path, file.content.as_bytes())?;
1061 if file.kind == buffa_codegen::GeneratedFileKind::PackageMod {
1062 output_entries.push((file.name, file.package));
1063 }
1064 }
1065
1066 // Generate the include file if requested.
1067 if let Some(ref include_name) = self.include_file {
1068 let include_content = generate_include_file(&output_entries, relative_includes);
1069 let include_path = out_dir.join(include_name);
1070 write_if_changed(&include_path, include_content.as_bytes())?;
1071 }
1072
1073 // Tell cargo to re-run if any proto file changes.
1074 //
1075 // For Buf mode, `self.files` are module-root-relative and cargo can't
1076 // stat them — use `buf ls-files` instead, which lists all workspace
1077 // protos with workspace-relative paths. This also catches changes to
1078 // transitively-imported protos (a gap in the Protoc mode, which only
1079 // watches explicitly-listed files).
1080 match self.descriptor_source {
1081 DescriptorSource::Buf => emit_buf_rerun_if_changed(),
1082 DescriptorSource::Protoc => {
1083 // Rerun if PROTOC changes (different binary may accept
1084 // protos the previous one rejected, e.g. newer editions).
1085 println!("cargo:rerun-if-env-changed=PROTOC");
1086 for proto_file in &self.files {
1087 println!("cargo:rerun-if-changed={}", proto_file.display());
1088 }
1089 }
1090 DescriptorSource::Precompiled(ref path) => {
1091 println!("cargo:rerun-if-changed={}", path.display());
1092 }
1093 }
1094
1095 Ok(())
1096 }
1097}
1098
1099impl Default for Config {
1100 fn default() -> Self {
1101 Self::new()
1102 }
1103}
1104
1105/// Normalize a user-supplied attribute-match path.
1106///
1107/// - Prepends `.` if absent so all stored paths are rooted.
1108/// - Trims trailing `.` so `".my.pkg."` and `".my.pkg"` behave identically
1109/// (trailing-dot patterns otherwise never match a real FQN).
1110/// - The bare catch-all `"."` is preserved as-is.
1111fn normalize_attr_path(mut path: String) -> String {
1112 if !path.starts_with('.') {
1113 path.insert(0, '.');
1114 }
1115 if path.len() > 1 {
1116 while path.ends_with('.') {
1117 path.pop();
1118 }
1119 }
1120 path
1121}
1122
1123/// Write `content` to `path` only if the file doesn't already exist with
1124/// identical content. Avoids bumping timestamps on unchanged files, which
1125/// prevents unnecessary downstream recompilation.
1126fn write_if_changed(path: &Path, content: &[u8]) -> std::io::Result<()> {
1127 if let Ok(existing) = std::fs::read(path) {
1128 if existing == content {
1129 return Ok(());
1130 }
1131 }
1132 std::fs::write(path, content)
1133}
1134
1135/// Invoke `protoc` to produce a `FileDescriptorSet` (serialized bytes).
1136fn invoke_protoc(
1137 files: &[PathBuf],
1138 includes: &[PathBuf],
1139) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
1140 let protoc = std::env::var("PROTOC").unwrap_or_else(|_| "protoc".to_string());
1141
1142 let descriptor_file =
1143 tempfile::NamedTempFile::new().map_err(|e| format!("failed to create temp file: {}", e))?;
1144 let descriptor_path = descriptor_file.path().to_path_buf();
1145
1146 let mut cmd = Command::new(&protoc);
1147 cmd.arg("--include_imports");
1148 cmd.arg("--include_source_info");
1149 cmd.arg(format!(
1150 "--descriptor_set_out={}",
1151 descriptor_path.display()
1152 ));
1153
1154 for include in includes {
1155 cmd.arg(format!("--proto_path={}", include.display()));
1156 }
1157
1158 for file in files {
1159 cmd.arg(file.as_os_str());
1160 }
1161
1162 let output = cmd
1163 .output()
1164 .map_err(|e| format!("failed to run protoc ({}): {}", protoc, e))?;
1165
1166 if !output.status.success() {
1167 let stderr = String::from_utf8_lossy(&output.stderr);
1168 return Err(format!("protoc failed: {}", stderr).into());
1169 }
1170
1171 let bytes = std::fs::read(&descriptor_path)
1172 .map_err(|e| format!("failed to read descriptor set: {}", e))?;
1173
1174 Ok(bytes)
1175}
1176
1177/// Invoke `buf build` to produce a `FileDescriptorSet` (serialized bytes).
1178///
1179/// Requires a `buf.yaml` discoverable from the build script's cwd. Builds
1180/// the entire workspace — no `--path` filtering, because buf's `--path` flag
1181/// expects workspace-relative paths while `FileDescriptorProto.name` is
1182/// module-root-relative; passing user paths to both would be a contradiction.
1183/// Codegen filtering happens on our side via `files_to_generate` matching.
1184fn invoke_buf() -> Result<Vec<u8>, Box<dyn std::error::Error>> {
1185 // buf build includes SourceCodeInfo by default (there's an
1186 // --exclude-source-info flag to disable it), so proto comments
1187 // propagate to generated code without an explicit opt-in here.
1188 let output = Command::new("buf")
1189 .arg("build")
1190 .arg("--as-file-descriptor-set")
1191 .arg("-o")
1192 .arg("-")
1193 .output()
1194 .map_err(|e| format!("failed to run buf (is it installed and on PATH?): {e}"))?;
1195
1196 if !output.status.success() {
1197 let stderr = String::from_utf8_lossy(&output.stderr);
1198 return Err(
1199 format!("buf build failed (is buf.yaml present at crate root?): {stderr}").into(),
1200 );
1201 }
1202
1203 Ok(output.stdout)
1204}
1205
1206/// Emit `cargo:rerun-if-changed` directives for a buf workspace.
1207///
1208/// Runs `buf ls-files` to discover all proto files with workspace-relative
1209/// paths (which cargo can stat). Also watches `buf.yaml` and `buf.lock`
1210/// (the latter only if it exists — cargo treats a missing rerun-if-changed
1211/// path as always-dirty). Failure is non-fatal: worst case cargo reruns
1212/// every build.
1213fn emit_buf_rerun_if_changed() {
1214 println!("cargo:rerun-if-changed=buf.yaml");
1215 if Path::new("buf.lock").exists() {
1216 println!("cargo:rerun-if-changed=buf.lock");
1217 }
1218 match Command::new("buf").arg("ls-files").output() {
1219 Ok(out) if out.status.success() => {
1220 for line in String::from_utf8_lossy(&out.stdout).lines() {
1221 let path = line.trim();
1222 if !path.is_empty() {
1223 println!("cargo:rerun-if-changed={path}");
1224 }
1225 }
1226 }
1227 _ => {
1228 // ls-files failed; cargo already knows about buf.yaml above.
1229 // If buf itself is missing, invoke_buf() will error clearly.
1230 }
1231 }
1232}
1233
1234/// Convert a filesystem proto path to the name protoc uses in the descriptor.
1235///
1236/// `FileDescriptorProto.name` is relative to the `--proto_path` include
1237/// directory. This strips the longest matching include prefix; if no include
1238/// matches, returns the path as-is (not just file_name — that would break
1239/// nested proto directories).
1240fn proto_relative_name(file: &Path, includes: &[PathBuf]) -> String {
1241 // Longest prefix wins: a file under both "proto/" and "proto/vendor/"
1242 // should strip "proto/vendor/" for a correct relative name.
1243 let mut best: Option<&Path> = None;
1244 for include in includes {
1245 if let Ok(rel) = file.strip_prefix(include) {
1246 match best {
1247 Some(prev) if prev.as_os_str().len() <= rel.as_os_str().len() => {}
1248 _ => best = Some(rel),
1249 }
1250 }
1251 }
1252 best.unwrap_or(file).to_str().unwrap_or("").to_string()
1253}
1254
1255/// Generate the content of an include file that assembles generated `.rs`
1256/// files into a nested module tree matching the protobuf package hierarchy.
1257///
1258/// Each generated file is named like `my.package.file_name.rs`. The package
1259/// segments become `pub mod` wrappers, and the file is `include!`d inside
1260/// the innermost module.
1261///
1262/// For example, files `["foo.bar.rs", "foo.baz.rs"]` produce:
1263/// ```text
1264/// pub mod foo {
1265/// #[allow(unused_imports)]
1266/// use super::*;
1267/// include!(concat!(env!("OUT_DIR"), "/foo.bar.rs"));
1268/// include!(concat!(env!("OUT_DIR"), "/foo.baz.rs"));
1269/// }
1270/// ```
1271///
1272/// When `relative` is true (the caller set [`Config::out_dir`] explicitly),
1273/// `include!` directives use bare sibling paths (`include!("foo.bar.rs")`)
1274/// instead of the `env!("OUT_DIR")` prefix, so the include file works when
1275/// checked into the source tree and referenced via `mod`.
1276fn generate_include_file(entries: &[(String, String)], relative: bool) -> String {
1277 let mode = if relative {
1278 buffa_codegen::IncludeMode::Relative("")
1279 } else {
1280 buffa_codegen::IncludeMode::OutDir
1281 };
1282 // Inner-allow off: this output is consumed via `include!` from
1283 // user-authored `lib.rs`, where `#![allow(...)]` is not valid.
1284 buffa_codegen::generate_module_tree(entries, mode, false)
1285}
1286
1287#[cfg(test)]
1288mod tests {
1289 use super::*;
1290
1291 #[test]
1292 fn unbox_oneof_in_normalizes_leading_dot() {
1293 // Without normalization a dotless path would silently match nothing,
1294 // and the exact-path recursion error would never fire for it.
1295 let config = Config::new()
1296 .unbox_oneof_in(&["my.pkg.Msg.body.small", ".my.pkg.Other"])
1297 .codegen_config;
1298 assert_eq!(
1299 config.unboxed_oneof_fields,
1300 vec![
1301 ".my.pkg.Msg.body.small".to_string(),
1302 ".my.pkg.Other".to_string()
1303 ]
1304 );
1305 }
1306
1307 #[test]
1308 fn proto_relative_name_strips_include() {
1309 let got = proto_relative_name(
1310 Path::new("proto/my/service.proto"),
1311 &[PathBuf::from("proto/")],
1312 );
1313 assert_eq!(got, "my/service.proto");
1314 }
1315
1316 #[test]
1317 fn proto_relative_name_longest_prefix_wins() {
1318 // Overlapping includes: file under both proto/ and proto/vendor/.
1319 // Must strip the LONGER prefix for the correct relative name.
1320 let got = proto_relative_name(
1321 Path::new("proto/vendor/ext.proto"),
1322 &[PathBuf::from("proto/"), PathBuf::from("proto/vendor/")],
1323 );
1324 assert_eq!(got, "ext.proto");
1325 // Same with reversed include order.
1326 let got = proto_relative_name(
1327 Path::new("proto/vendor/ext.proto"),
1328 &[PathBuf::from("proto/vendor/"), PathBuf::from("proto/")],
1329 );
1330 assert_eq!(got, "ext.proto");
1331 }
1332
1333 #[test]
1334 fn proto_relative_name_no_match_returns_full_path() {
1335 // Regression: previously fell back to file_name(), which stripped
1336 // directory components and broke descriptor_set() mode with nested
1337 // proto packages. Now returns the full path as-is.
1338 let got = proto_relative_name(Path::new("my/pkg/service.proto"), &[]);
1339 assert_eq!(got, "my/pkg/service.proto");
1340 }
1341
1342 #[test]
1343 fn proto_relative_name_no_match_with_unrelated_includes() {
1344 let got = proto_relative_name(
1345 Path::new("src/my.proto"),
1346 &[PathBuf::from("other/"), PathBuf::from("third/")],
1347 );
1348 assert_eq!(got, "src/my.proto");
1349 }
1350
1351 #[test]
1352 fn include_file_out_dir_mode_uses_env_var() {
1353 let entries = vec![
1354 ("foo.bar.rs".to_string(), "foo".to_string()),
1355 ("root.rs".to_string(), String::new()),
1356 ];
1357 let out = generate_include_file(&entries, false);
1358 assert!(
1359 out.contains(r#"include!(concat!(env!("OUT_DIR"), "/foo.bar.rs"));"#),
1360 "nested-package file should use env!(OUT_DIR): {out}"
1361 );
1362 assert!(
1363 out.contains(r#"include!(concat!(env!("OUT_DIR"), "/root.rs"));"#),
1364 "empty-package file should use env!(OUT_DIR): {out}"
1365 );
1366 assert!(!out.contains(r#"include!("foo.bar.rs")"#));
1367 }
1368
1369 #[test]
1370 fn include_file_relative_mode_uses_sibling_paths() {
1371 let entries = vec![
1372 ("foo.bar.rs".to_string(), "foo".to_string()),
1373 ("root.rs".to_string(), String::new()),
1374 ];
1375 let out = generate_include_file(&entries, true);
1376 assert!(
1377 out.contains(r#"include!("foo.bar.rs");"#),
1378 "nested-package file should use relative path: {out}"
1379 );
1380 assert!(
1381 out.contains(r#"include!("root.rs");"#),
1382 "empty-package file should use relative path: {out}"
1383 );
1384 assert!(
1385 !out.contains("OUT_DIR"),
1386 "relative mode must not reference OUT_DIR: {out}"
1387 );
1388 }
1389
1390 #[test]
1391 fn include_file_relative_mode_nested_packages() {
1392 // Two files in the same depth-2 package: verifies the relative flag
1393 // propagates through recursive emit() calls and both files land in
1394 // the same innermost mod.
1395 let entries = vec![
1396 ("a.b.one.rs".to_string(), "a.b".to_string()),
1397 ("a.b.two.rs".to_string(), "a.b".to_string()),
1398 ];
1399 let out = generate_include_file(&entries, true);
1400 // Both includes should appear once, at the same depth-2 indent,
1401 // inside a single `pub mod b { ... }`.
1402 let indent = " "; // depth 2 = 8 spaces
1403 assert!(
1404 out.contains(&format!(r#"{indent}include!("a.b.one.rs");"#)),
1405 "first file at depth 2: {out}"
1406 );
1407 assert!(
1408 out.contains(&format!(r#"{indent}include!("a.b.two.rs");"#)),
1409 "second file at depth 2: {out}"
1410 );
1411 assert_eq!(
1412 out.matches("pub mod b {").count(),
1413 1,
1414 "both files share one `mod b`: {out}"
1415 );
1416 assert!(!out.contains("OUT_DIR"));
1417 }
1418
1419 #[test]
1420 fn write_if_changed_creates_new_file() {
1421 let dir = tempfile::tempdir().unwrap();
1422 let path = dir.path().join("new.rs");
1423 write_if_changed(&path, b"hello").unwrap();
1424 assert_eq!(std::fs::read(&path).unwrap(), b"hello");
1425 }
1426
1427 #[test]
1428 fn write_if_changed_skips_identical_content() {
1429 let dir = tempfile::tempdir().unwrap();
1430 let path = dir.path().join("same.rs");
1431 std::fs::write(&path, b"content").unwrap();
1432 let mtime_before = std::fs::metadata(&path).unwrap().modified().unwrap();
1433
1434 // Sleep briefly so any write would produce a different mtime.
1435 std::thread::sleep(std::time::Duration::from_millis(50));
1436
1437 write_if_changed(&path, b"content").unwrap();
1438 let mtime_after = std::fs::metadata(&path).unwrap().modified().unwrap();
1439 assert_eq!(mtime_before, mtime_after);
1440 }
1441
1442 #[test]
1443 fn write_if_changed_overwrites_different_content() {
1444 let dir = tempfile::tempdir().unwrap();
1445 let path = dir.path().join("changed.rs");
1446 std::fs::write(&path, b"old").unwrap();
1447
1448 write_if_changed(&path, b"new").unwrap();
1449 assert_eq!(std::fs::read(&path).unwrap(), b"new");
1450 }
1451
1452 #[test]
1453 fn normalize_attr_path_prepends_leading_dot() {
1454 assert_eq!(normalize_attr_path("my.pkg".into()), ".my.pkg");
1455 }
1456
1457 #[test]
1458 fn normalize_attr_path_preserves_leading_dot() {
1459 assert_eq!(normalize_attr_path(".my.pkg".into()), ".my.pkg");
1460 }
1461
1462 #[test]
1463 fn normalize_attr_path_trims_trailing_dot() {
1464 assert_eq!(normalize_attr_path("my.pkg.".into()), ".my.pkg");
1465 assert_eq!(normalize_attr_path(".my.pkg.".into()), ".my.pkg");
1466 assert_eq!(normalize_attr_path(".my.pkg...".into()), ".my.pkg");
1467 }
1468
1469 #[test]
1470 fn normalize_attr_path_preserves_catchall() {
1471 assert_eq!(normalize_attr_path(".".into()), ".");
1472 assert_eq!(normalize_attr_path("".into()), ".");
1473 }
1474
1475 #[test]
1476 fn type_attribute_forwards_normalized_path() {
1477 let cfg = Config::new().type_attribute("my.pkg.", "#[derive(Foo)]");
1478 assert_eq!(
1479 cfg.codegen_config.type_attributes,
1480 vec![(".my.pkg".to_string(), "#[derive(Foo)]".to_string())]
1481 );
1482 }
1483
1484 #[test]
1485 fn field_attribute_forwards_normalized_path() {
1486 let cfg = Config::new().field_attribute("pkg.Msg.f", "#[serde(skip)]");
1487 assert_eq!(
1488 cfg.codegen_config.field_attributes,
1489 vec![(".pkg.Msg.f".to_string(), "#[serde(skip)]".to_string())]
1490 );
1491 }
1492
1493 #[test]
1494 fn message_attribute_forwards_normalized_path() {
1495 let cfg = Config::new().message_attribute(".", "#[serde(default)]");
1496 assert_eq!(
1497 cfg.codegen_config.message_attributes,
1498 vec![(".".to_string(), "#[serde(default)]".to_string())]
1499 );
1500 }
1501
1502 #[test]
1503 fn enum_attribute_forwards_normalized_path() {
1504 let cfg = Config::new().enum_attribute("my.pkg.", "#[derive(strum::EnumIter)]");
1505 assert_eq!(
1506 cfg.codegen_config.enum_attributes,
1507 vec![(
1508 ".my.pkg".to_string(),
1509 "#[derive(strum::EnumIter)]".to_string(),
1510 )]
1511 );
1512 // Other attribute lists must remain untouched.
1513 assert!(cfg.codegen_config.type_attributes.is_empty());
1514 assert!(cfg.codegen_config.message_attributes.is_empty());
1515 assert!(cfg.codegen_config.field_attributes.is_empty());
1516 }
1517
1518 #[test]
1519 fn attribute_calls_accumulate_in_insertion_order() {
1520 let cfg = Config::new()
1521 .type_attribute(".", "#[derive(A)]")
1522 .type_attribute(".pkg.M", "#[derive(B)]")
1523 .type_attribute(".", "#[derive(C)]");
1524 let paths: Vec<_> = cfg
1525 .codegen_config
1526 .type_attributes
1527 .iter()
1528 .map(|(_, a)| a.as_str())
1529 .collect();
1530 assert_eq!(paths, vec!["#[derive(A)]", "#[derive(B)]", "#[derive(C)]"]);
1531 }
1532}