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 /// # Performance
317 ///
318 /// In the default vtable mode, `reflect()` borrows `self` — no round-trip,
319 /// no allocation; reflective accessors read fields in place. (Bridge mode
320 /// instead pays one encode/decode round-trip plus a heap allocation per
321 /// call.) Either way the first call pays a one-time pool build cost.
322 ///
323 /// # Build time and binary size
324 ///
325 /// Each generated package embeds its own copy of the full
326 /// `FileDescriptorSet` (transitive closure). For a single-package
327 /// crate this is one copy. For a multi-package codegen run the bytes
328 /// duplicate per package — measurable for large proto trees. The
329 /// serialization happens once per `compile()` call (not per package),
330 /// so build-time CPU does not scale with package count. Vtable mode also
331 /// emits an `impl ReflectMessage` per type, so it produces more code than
332 /// bridge mode.
333 ///
334 /// [`ReflectCow`]: https://docs.rs/buffa-descriptor/latest/buffa_descriptor/reflect/enum.ReflectCow.html
335 /// [`DynamicMessage`]: https://docs.rs/buffa-descriptor/latest/buffa_descriptor/reflect/struct.DynamicMessage.html
336 /// [`DescriptorPool`]: https://docs.rs/buffa-descriptor/latest/buffa_descriptor/struct.DescriptorPool.html
337 #[must_use]
338 pub fn generate_reflection(mut self, enabled: bool) -> Self {
339 // The simple on/off knob selects the fast vtable path; Bridge is opt-in
340 // via `reflect_mode`.
341 let mode = if enabled {
342 ReflectMode::VTable
343 } else {
344 ReflectMode::Off
345 };
346 mode.apply(&mut self.codegen_config);
347 self
348 }
349
350 /// Select the reflection mode (the fuller form of
351 /// [`generate_reflection`](Self::generate_reflection)).
352 ///
353 /// - [`ReflectMode::Off`] — no reflection (the default); equivalent to
354 /// `generate_reflection(false)`.
355 /// - [`ReflectMode::Bridge`] — `reflect()` round-trips through
356 /// `DynamicMessage`; smaller generated code, slower reflective access.
357 /// - [`ReflectMode::VTable`] — `impl ReflectMessage` on owned and view
358 /// types, and `reflect()` borrows `self` with no round-trip; equivalent
359 /// to `generate_reflection(true)`. Does not require view generation —
360 /// with views off, only the owned impls are emitted.
361 ///
362 /// All non-`Off` modes require the consuming crate to depend on
363 /// `buffa-descriptor` with its `reflect` feature and on `std`. The call
364 /// site (`foo.reflect().get(fd)`) is identical across modes.
365 #[must_use]
366 pub fn reflect_mode(mut self, mode: ReflectMode) -> Self {
367 mode.apply(&mut self.codegen_config);
368 self
369 }
370
371 /// Enable or disable idiomatic `UpperCamelCase` enum aliases (matches the
372 /// [`CodeGenConfig`] default, currently on).
373 ///
374 /// Protobuf enum values are `SHOUTY_SNAKE_CASE` and stay the definitive Rust
375 /// variants. When enabled, codegen additionally emits associated `const`s
376 /// with the enum-name prefix stripped and the name converted to
377 /// `UpperCamelCase` (`RULE_LEVEL_HIGH` → `RuleLevel::High`), purely
378 /// additively — existing references and `Debug` output are unchanged.
379 ///
380 /// Aliases are suppressed per enum (with a build warning and a doc note) if
381 /// any two values would collide after conversion, so a match is never forced
382 /// to mix conventions. See [`CodeGenConfig::idiomatic_enum_aliases`].
383 #[must_use]
384 pub fn idiomatic_enum_aliases(mut self, enabled: bool) -> Self {
385 self.codegen_config.idiomatic_enum_aliases = enabled;
386 self
387 }
388
389 /// Enable or disable unknown field preservation (default: true).
390 ///
391 /// When enabled (the default), unrecognized fields encountered during
392 /// decode are stored and re-emitted on encode — essential for proxy /
393 /// middleware services and round-trip fidelity across schema versions.
394 ///
395 /// **Disabling is primarily a memory optimization** (24 bytes/message for
396 /// the `UnknownFields` Vec header), not a throughput one. When no unknown
397 /// fields appear on the wire — the common case for schema-aligned
398 /// services — decode and encode costs are effectively identical in
399 /// either mode. Consider disabling for embedded / `no_std` targets or
400 /// large in-memory collections of small messages.
401 #[must_use]
402 pub fn preserve_unknown_fields(mut self, enabled: bool) -> Self {
403 self.codegen_config.preserve_unknown_fields = enabled;
404 self
405 }
406
407 /// Honor `features.utf8_validation = NONE` by emitting `Vec<u8>` / `&[u8]`
408 /// for such string fields instead of `String` / `&str` (default: false).
409 ///
410 /// When disabled (the default), all string fields map to `String` and
411 /// UTF-8 is validated on decode — stricter than proto2 requires, but
412 /// ergonomic and safe.
413 ///
414 /// When enabled, string fields with `utf8_validation = NONE` become
415 /// `Vec<u8>` / `&[u8]`. Decode skips validation; the caller chooses
416 /// whether to `std::str::from_utf8` (checked) or `from_utf8_unchecked`
417 /// (trusted-input fast path). This is the only sound Rust mapping when
418 /// strings may actually contain non-UTF-8 bytes.
419 ///
420 /// **Note for proto2 users**: proto2's default is `utf8_validation = NONE`,
421 /// so enabling this turns ALL proto2 string fields into `Vec<u8>`. Use
422 /// only for new code or when profiling identifies UTF-8 validation as a
423 /// bottleneck (it can be 10%+ of decode CPU for string-heavy messages).
424 ///
425 /// **JSON note**: fields normalized to bytes serialize as base64 in JSON
426 /// (the proto3 JSON encoding for `bytes`). Keep strict mapping disabled
427 /// for fields that need JSON string interop with other implementations.
428 ///
429 /// **Interaction with [`use_bytes_type`]**: when both are enabled,
430 /// `map<bytes, bytes>` values stay `Vec<u8>` (the bytes-keyed JSON helper
431 /// is concrete `HashMap<Vec<u8>, Vec<u8>>`). All other `bytes` shapes —
432 /// singular / optional / repeated / oneof / `map<non-bytes, bytes>` —
433 /// still become `bytes::Bytes`. The asymmetry is documented; if you hit
434 /// it, see issue #76.
435 ///
436 /// [`use_bytes_type`]: Self::use_bytes_type
437 #[must_use]
438 pub fn strict_utf8_mapping(mut self, enabled: bool) -> Self {
439 self.codegen_config.strict_utf8_mapping = enabled;
440 self
441 }
442
443 /// Permit `option message_set_wire_format = true` on input messages.
444 ///
445 /// MessageSet is a legacy Google-internal wire format. Default: `false`
446 /// (such messages produce a codegen error). Set to `true` only when
447 /// compiling protos that interoperate with old Google-internal services.
448 #[must_use]
449 pub fn allow_message_set(mut self, enabled: bool) -> Self {
450 self.codegen_config.allow_message_set = enabled;
451 self
452 }
453
454 /// Declare an external type path mapping.
455 ///
456 /// The matched types reference the specified Rust path instead of being
457 /// generated. This allows shared proto packages to be compiled once in a
458 /// dedicated crate and referenced from others.
459 ///
460 /// `proto_path` is a fully-qualified protobuf path — either a **package**
461 /// (`".my.common"`, mapping every type under it to a Rust module root) or a
462 /// single **type FQN** (`".google.protobuf.Timestamp"`, mapping just that
463 /// type, the prost/tonic idiom). The leading dot is optional and is added
464 /// automatically. As in prost, the most specific entry wins: an exact type
465 /// FQN beats a covering package prefix, which in turn beats a shorter
466 /// prefix.
467 ///
468 /// `rust_path` is where the type(s) are accessible — a module root for a
469 /// package mapping (e.g. `"::common_protos"`) or a full type path for a
470 /// per-type mapping (e.g. `"::pbjson_types::Timestamp"`). It must be an
471 /// absolute path (starting with `::` or `crate::`); any other value is
472 /// emitted into the generated code verbatim and will fail to resolve there.
473 ///
474 /// **Nested types** inherit an enclosing message's per-type override:
475 /// mapping `.my.pkg.Outer` to `::ext::Outer` resolves `.my.pkg.Outer.Inner`
476 /// to `::ext::outer::Inner` — the override's parent module plus buffa's
477 /// usual `snake_case(MessageName)` nested-types module (snake case of the
478 /// *proto* message name, regardless of the override's final segment). This
479 /// matches the layout of another buffa-generated crate; for a target crate
480 /// laid out differently, add explicit per-type entries for the nested types
481 /// as well.
482 ///
483 /// # Limitations
484 ///
485 /// An extern type that is referenced by a generated **view** must map to
486 /// another buffa-generated crate — the view path is composed as
487 /// `<rust_path_root>::__buffa::view::…`, which a non-buffa crate (e.g.
488 /// `pbjson_types`) does not provide. Map per-type to a buffa crate, or
489 /// disable views ([`generate_views(false)`](Self::generate_views)), for
490 /// such types.
491 ///
492 /// A misconfigured mapping (a typo'd FQN target, a non-absolute
493 /// `rust_path`, or a view-referenced type mapped to a non-buffa crate) is
494 /// not diagnosed at generation time; it surfaces as an unresolved-path
495 /// error when the generated code is compiled.
496 ///
497 /// # Example
498 ///
499 /// ```rust,ignore
500 /// buffa_build::Config::new()
501 /// // Whole-package mapping.
502 /// .extern_path(".my.common", "::common_protos")
503 /// // Per-type mapping (issue #111) — overrides the package prefix for
504 /// // just this type.
505 /// .extern_path(".google.protobuf.Timestamp", "::common_protos::well_known::Timestamp")
506 /// .files(&["proto/my_service.proto"])
507 /// .includes(&["proto/"])
508 /// .compile()
509 /// .unwrap();
510 /// ```
511 #[must_use]
512 pub fn extern_path(
513 mut self,
514 proto_path: impl Into<String>,
515 rust_path: impl Into<String>,
516 ) -> Self {
517 let mut proto_path = proto_path.into();
518 // Normalize: ensure the proto path is fully-qualified (leading dot).
519 // Accept both ".my.package" and "my.package" for convenience.
520 if !proto_path.starts_with('.') {
521 proto_path.insert(0, '.');
522 }
523 self.codegen_config
524 .extern_paths
525 .push((proto_path, rust_path.into()));
526 self
527 }
528
529 /// Configure `bytes` fields to use `bytes::Bytes` instead of `Vec<u8>`.
530 ///
531 /// Each path is a fully-qualified proto path prefix. Use `"."` to apply
532 /// to all bytes fields, or specify individual field paths like
533 /// `".my.pkg.MyMessage.data"`.
534 ///
535 /// Applies uniformly to singular, optional, repeated, oneof, **and
536 /// `map<K, bytes>`** values — the map case lets `view → owned`
537 /// conversion participate in the `to_owned_from_source` zero-copy
538 /// `slice_ref` path. One carve-out: an effective `map<bytes, bytes>` keeps
539 /// `Vec<u8>` values (the JSON helper for that combination is concrete
540 /// `HashMap<Vec<u8>, Vec<u8>>`); every other shape becomes `Bytes`. A
541 /// `bytes` map key is only reachable when [`strict_utf8_mapping`] is enabled
542 /// *and* the `map<string, bytes>` field carries
543 /// `[features.utf8_validation = NONE]` on its key, which normalizes the
544 /// string key to `bytes` — `strict_utf8_mapping` alone does not trigger it.
545 ///
546 /// [`strict_utf8_mapping`]: Self::strict_utf8_mapping
547 ///
548 /// # Example
549 ///
550 /// ```rust,ignore
551 /// buffa_build::Config::new()
552 /// .use_bytes_type_in(&["."]) // all bytes fields use Bytes
553 /// .files(&["proto/my_service.proto"])
554 /// .includes(&["proto/"])
555 /// .compile()
556 /// .unwrap();
557 /// ```
558 #[must_use]
559 pub fn use_bytes_type_in(mut self, paths: &[impl AsRef<str>]) -> Self {
560 self.codegen_config
561 .bytes_fields
562 .extend(paths.iter().map(|p| p.as_ref().to_string()));
563 self
564 }
565
566 /// Use `bytes::Bytes` for all `bytes` fields in all messages.
567 ///
568 /// This is a convenience for `.use_bytes_type_in(&["."])`. Use
569 /// [`use_bytes_type_in`] with specific proto paths if you only want `Bytes`
570 /// for certain fields. See that method for the path-matching semantics, the
571 /// `map<K, bytes>` rule, and the `map<bytes, bytes>` carve-out under
572 /// [`strict_utf8_mapping`].
573 ///
574 /// [`use_bytes_type_in`]: Self::use_bytes_type_in
575 /// [`strict_utf8_mapping`]: Self::strict_utf8_mapping
576 #[must_use]
577 pub fn use_bytes_type(mut self) -> Self {
578 self.codegen_config.bytes_fields.push(".".to_string());
579 self
580 }
581
582 /// Map `string` fields to a [`StringRepr`] other than `String` for the
583 /// given proto path prefixes. The string counterpart to
584 /// [`use_bytes_type_in`](Self::use_bytes_type_in).
585 ///
586 /// Each path is a fully-qualified proto path prefix (e.g.
587 /// `".my.pkg.MyMessage.name"` for one field, `".my.pkg"` for a package).
588 ///
589 /// Rules accumulate and the **last** matching rule wins. Order therefore
590 /// matters: call [`string_type`](Self::string_type) (the broad default)
591 /// *first*, then `string_type_in` for narrower overrides — a broad rule
592 /// added after a specific one will shadow it.
593 ///
594 /// The downstream crate must enable the selected type's `buffa` feature
595 /// (`smol_str`, `ecow`, or `compact_str`); otherwise the generated
596 /// `::buffa::<crate>::<Type>` references fail to resolve.
597 ///
598 /// Only the owned Rust type changes: the wire format is unchanged, view
599 /// types still borrow `&str`, and `map<_, string>` keys and values stay
600 /// `String`.
601 ///
602 /// # Example
603 ///
604 /// ```rust,ignore
605 /// use buffa_build::StringRepr;
606 /// buffa_build::Config::new()
607 /// .string_type(StringRepr::SmolStr) // broad default first
608 /// .string_type_in(StringRepr::CompactString, &[".my.pkg.Msg.body"]) // narrow override
609 /// .files(&["proto/my_service.proto"])
610 /// .includes(&["proto/"])
611 /// .compile()
612 /// .unwrap();
613 /// ```
614 #[must_use]
615 pub fn string_type_in(mut self, repr: StringRepr, paths: &[impl AsRef<str>]) -> Self {
616 self.codegen_config
617 .string_fields
618 .extend(paths.iter().map(|p| (p.as_ref().to_string(), repr)));
619 self
620 }
621
622 /// Map every `string` field in all messages to the given [`StringRepr`].
623 ///
624 /// Convenience for `.string_type_in(repr, &["."])`. Call this *before* any
625 /// [`string_type_in`](Self::string_type_in) overrides, since the last
626 /// matching rule wins (a `"."` rule added later shadows earlier specific
627 /// rules). `map<_, string>` keys and values stay `String`, and the
628 /// downstream crate must enable the selected type's `buffa` feature.
629 #[must_use]
630 pub fn string_type(mut self, repr: StringRepr) -> Self {
631 self.codegen_config
632 .string_fields
633 .push((".".to_string(), repr));
634 self
635 }
636
637 /// Add a custom attribute to generated types (messages and enums)
638 /// matching a proto path prefix.
639 ///
640 /// `path` is a fully-qualified proto path prefix: `"."` applies to all
641 /// types, `".my.pkg"` to types in that package, `".my.pkg.MyMessage"`
642 /// to a specific type. A leading `.` is auto-prepended if omitted; a
643 /// trailing `.` is trimmed. Prefix matching respects proto-segment
644 /// boundaries, so `".my.pk"` does not match `".my.pkg.Msg"`.
645 ///
646 /// `attribute` is a raw Rust attribute string
647 /// (e.g., `"#[derive(serde::Serialize)]"`). A malformed attribute
648 /// produces [`CodeGenError::InvalidCustomAttribute`](buffa_codegen::CodeGenError)
649 /// at compile time rather than being silently dropped.
650 ///
651 /// Multiple calls accumulate in insertion order — all matching attributes
652 /// are emitted, and ordering is preserved in generated code.
653 ///
654 /// Also applies to generated oneof enums when `path` matches
655 /// `".pkg.Msg.my_oneof"` (the oneof's fully-qualified path).
656 ///
657 /// # Pitfalls
658 ///
659 /// buffa already emits `#[derive(Clone, PartialEq)]` on messages and
660 /// `#[derive(Clone, PartialEq, Debug)]` on oneofs; adding a duplicate
661 /// derive via `type_attribute(".", "#[derive(Clone)]")` produces a
662 /// compile error in the generated code.
663 ///
664 /// # Example
665 ///
666 /// ```rust,ignore
667 /// buffa_build::Config::new()
668 /// .type_attribute(".", "#[derive(serde::Serialize)]")
669 /// .type_attribute(".my.pkg.MyEnum", "#[derive(strum::EnumIter)]")
670 /// .files(&["proto/my_service.proto"])
671 /// .includes(&["proto/"])
672 /// .compile()
673 /// .unwrap();
674 /// ```
675 #[must_use]
676 pub fn type_attribute(mut self, path: impl Into<String>, attribute: impl Into<String>) -> Self {
677 self.codegen_config
678 .type_attributes
679 .push((normalize_attr_path(path.into()), attribute.into()));
680 self
681 }
682
683 /// Add a custom attribute to generated struct fields matching a proto
684 /// path prefix.
685 ///
686 /// `path` is a fully-qualified proto field path (e.g.,
687 /// `".my.pkg.MyMessage.my_field"`). `"."` applies to all fields. A
688 /// leading `.` is auto-prepended if omitted; a trailing `.` is trimmed.
689 /// Prefix matching respects proto-segment boundaries.
690 ///
691 /// Also applies to oneof variants when `path` matches
692 /// `".pkg.Msg.my_oneof.variant_name"`.
693 ///
694 /// # Example
695 ///
696 /// ```rust,ignore
697 /// buffa_build::Config::new()
698 /// .field_attribute(".my.pkg.MyMessage.secret_key", "#[serde(skip)]")
699 /// .files(&["proto/my_service.proto"])
700 /// .includes(&["proto/"])
701 /// .compile()
702 /// .unwrap();
703 /// ```
704 #[must_use]
705 pub fn field_attribute(
706 mut self,
707 path: impl Into<String>,
708 attribute: impl Into<String>,
709 ) -> Self {
710 self.codegen_config
711 .field_attributes
712 .push((normalize_attr_path(path.into()), attribute.into()));
713 self
714 }
715
716 /// Add a custom attribute to generated message structs only (not enums,
717 /// not oneof enums) matching a proto path prefix.
718 ///
719 /// Same path-matching semantics as [`type_attribute`](Self::type_attribute) —
720 /// leading `.` auto-prepended, trailing `.` trimmed, proto-segment-aware
721 /// prefix matching, accumulation in insertion order. A malformed attribute
722 /// produces a compile-time error. Useful for struct-only attributes like
723 /// `#[serde(default)]`.
724 ///
725 /// # Example
726 ///
727 /// ```rust,ignore
728 /// buffa_build::Config::new()
729 /// .message_attribute(".", "#[serde(default)]")
730 /// .files(&["proto/my_service.proto"])
731 /// .includes(&["proto/"])
732 /// .compile()
733 /// .unwrap();
734 /// ```
735 #[must_use]
736 pub fn message_attribute(
737 mut self,
738 path: impl Into<String>,
739 attribute: impl Into<String>,
740 ) -> Self {
741 self.codegen_config
742 .message_attributes
743 .push((normalize_attr_path(path.into()), attribute.into()));
744 self
745 }
746
747 /// Add a custom attribute to generated enum types only (not message
748 /// structs, not oneof enums) matching a proto path prefix.
749 ///
750 /// Same path-matching semantics as [`type_attribute`](Self::type_attribute) —
751 /// leading `.` auto-prepended, trailing `.` trimmed, proto-segment-aware
752 /// prefix matching, accumulation in insertion order. A malformed attribute
753 /// produces a compile-time error. Useful when you want to inject an
754 /// attribute on every enum in a package without also matching the
755 /// (often more numerous) messages that share the path prefix — e.g.
756 /// `#[derive(strum::EnumIter)]`, which only makes sense on enums.
757 ///
758 /// # Example
759 ///
760 /// ```rust,ignore
761 /// buffa_build::Config::new()
762 /// .enum_attribute(".my.pkg", "#[derive(strum::EnumIter)]")
763 /// .files(&["proto/my_service.proto"])
764 /// .includes(&["proto/"])
765 /// .compile()
766 /// .unwrap();
767 /// ```
768 #[must_use]
769 pub fn enum_attribute(mut self, path: impl Into<String>, attribute: impl Into<String>) -> Self {
770 self.codegen_config
771 .enum_attributes
772 .push((normalize_attr_path(path.into()), attribute.into()));
773 self
774 }
775
776 /// Use `buf build` instead of `protoc` for descriptor generation.
777 ///
778 /// `buf` is often easier to install and keep current than `protoc`
779 /// (which many distros pin to old versions). This mode is intended for
780 /// the **single-crate case**: a `buf.yaml` at the crate root defining
781 /// the module layout.
782 ///
783 /// Requires `buf` on PATH and a `buf.yaml` at the crate root. The
784 /// [`includes()`](Self::includes) setting is ignored — buf resolves
785 /// imports via its own module configuration.
786 ///
787 /// Each path given to [`files()`](Self::files) must be **relative to its
788 /// owning module's directory** (the `path:` value inside `buf.yaml`), not
789 /// the crate root where `buf.yaml` itself lives. buf strips the module
790 /// path when producing `FileDescriptorProto.name`, so for
791 /// `modules: [{path: proto}]` and a file on disk at
792 /// `proto/api/v1/service.proto`, the descriptor name is
793 /// `api/v1/service.proto` — that is what `.files()` must contain.
794 /// Multiple modules in one `buf.yaml` work fine; buf enforces that
795 /// module-relative names are unique across the workspace.
796 ///
797 /// # Monorepo / multi-module setups
798 ///
799 /// For a workspace-root `buf.yaml` with many modules, this mode is a
800 /// poor fit. Prefer running `buf generate` with the `protoc-gen-buffa`
801 /// plugin and checking in the generated code, or use
802 /// [`descriptor_set()`](Self::descriptor_set) with the output of
803 /// `buf build --as-file-descriptor-set -o fds.binpb <module-path>`
804 /// run as a pre-build step.
805 ///
806 /// # Example
807 ///
808 /// ```rust,ignore
809 /// // buf.yaml (at crate root):
810 /// // version: v2
811 /// // modules:
812 /// // - path: proto
813 /// //
814 /// // build.rs:
815 /// buffa_build::Config::new()
816 /// .use_buf()
817 /// .files(&["api/v1/service.proto"]) // relative to module root
818 /// .compile()
819 /// .unwrap();
820 /// ```
821 #[must_use]
822 pub fn use_buf(mut self) -> Self {
823 self.descriptor_source = DescriptorSource::Buf;
824 self
825 }
826
827 /// Use a pre-compiled `FileDescriptorSet` binary file as input.
828 ///
829 /// Skips invoking `protoc` or `buf` entirely. The file must contain a
830 /// serialized `google.protobuf.FileDescriptorSet` (as produced by
831 /// `protoc --descriptor_set_out` or `buf build --as-file-descriptor-set`).
832 ///
833 /// When using this, `.files()` specifies which proto files in the
834 /// descriptor set to generate code for (matching by proto file name).
835 #[must_use]
836 pub fn descriptor_set(mut self, path: impl Into<PathBuf>) -> Self {
837 self.descriptor_source = DescriptorSource::Precompiled(path.into());
838 self
839 }
840
841 /// Generate a module-tree include file alongside the per-package `.rs`
842 /// files.
843 ///
844 /// The include file contains nested `pub mod` declarations with
845 /// `include!()` directives that assemble the generated code into a
846 /// module hierarchy matching the protobuf package structure. Users can
847 /// then include this single file instead of manually creating the
848 /// module tree.
849 ///
850 /// The form of the emitted `include!` directives depends on whether
851 /// [`out_dir`](Self::out_dir) was set:
852 ///
853 /// - **Default (`$OUT_DIR`)**: emits
854 /// `include!(concat!(env!("OUT_DIR"), "/foo.rs"))`, for use from
855 /// `build.rs` via `include!(concat!(env!("OUT_DIR"), "/<name>"))`.
856 /// - **Explicit `out_dir`**: emits sibling-relative `include!("foo.rs")`,
857 /// for checking the generated code into the source tree and referencing
858 /// it as a module (e.g. `mod gen;`).
859 ///
860 /// # Example — `build.rs` / `$OUT_DIR`
861 ///
862 /// ```rust,ignore
863 /// // build.rs
864 /// buffa_build::Config::new()
865 /// .files(&["proto/my_service.proto"])
866 /// .includes(&["proto/"])
867 /// .include_file("_include.rs")
868 /// .compile()
869 /// .unwrap();
870 ///
871 /// // lib.rs
872 /// include!(concat!(env!("OUT_DIR"), "/_include.rs"));
873 /// ```
874 ///
875 /// # Example — checked-in source
876 ///
877 /// ```rust,ignore
878 /// // codegen.rs (run manually, not from build.rs)
879 /// buffa_build::Config::new()
880 /// .files(&["proto/my_service.proto"])
881 /// .includes(&["proto/"])
882 /// .out_dir("src/gen")
883 /// .include_file("mod.rs")
884 /// .compile()
885 /// .unwrap();
886 ///
887 /// // lib.rs
888 /// mod gen;
889 /// ```
890 #[must_use]
891 pub fn include_file(mut self, name: impl Into<String>) -> Self {
892 self.include_file = Some(name.into());
893 self
894 }
895
896 /// Compile proto files and generate Rust source.
897 ///
898 /// # Errors
899 ///
900 /// Returns an error if:
901 /// - `OUT_DIR` is not set and no `out_dir` was configured
902 /// - `protoc` or `buf` cannot be found on `PATH` (when using those sources)
903 /// - the proto compiler exits with a non-zero status (syntax errors,
904 /// missing imports, etc.)
905 /// - a precompiled descriptor set file cannot be read
906 /// - the descriptor set bytes cannot be decoded as a `FileDescriptorSet`
907 /// - code generation fails (e.g. unsupported proto feature)
908 /// - the output directory cannot be created or written to
909 pub fn compile(self) -> Result<(), Box<dyn std::error::Error>> {
910 // When out_dir is explicitly set, the include file should use
911 // relative `include!("foo.rs")` paths (the index is a sibling of the
912 // generated files). When defaulted to $OUT_DIR, keep the
913 // `concat!(env!("OUT_DIR"), ...)` form so that
914 // `include!(concat!(env!("OUT_DIR"), "/_include.rs"))` from src/
915 // still resolves to absolute paths.
916 let relative_includes = self.out_dir.is_some();
917 let out_dir = self
918 .out_dir
919 .or_else(|| std::env::var("OUT_DIR").ok().map(PathBuf::from))
920 .ok_or("OUT_DIR not set and no out_dir configured")?;
921
922 // Produce a FileDescriptorSet from the configured source.
923 let descriptor_bytes = match &self.descriptor_source {
924 DescriptorSource::Protoc => invoke_protoc(&self.files, &self.includes)?,
925 DescriptorSource::Buf => invoke_buf()?,
926 DescriptorSource::Precompiled(path) => std::fs::read(path).map_err(|e| {
927 format!("failed to read descriptor set '{}': {}", path.display(), e)
928 })?,
929 };
930 let fds = FileDescriptorSet::decode_from_slice(&descriptor_bytes)
931 .map_err(|e| format!("failed to decode FileDescriptorSet: {}", e))?;
932
933 // Determine which files were explicitly requested.
934 //
935 // `FileDescriptorProto.name` contains the path relative to the proto
936 // source root (protoc: `--proto_path`; buf: the module root). For
937 // Precompiled and Buf mode, `.files()` are expected to already be
938 // proto-relative names. For Protoc mode, strip the longest matching
939 // include prefix.
940 let files_to_generate: Vec<String> = if matches!(
941 self.descriptor_source,
942 DescriptorSource::Precompiled(_) | DescriptorSource::Buf
943 ) {
944 self.files
945 .iter()
946 .filter_map(|f| f.to_str().map(str::to_string))
947 .collect()
948 } else {
949 self.files
950 .iter()
951 .map(|f| proto_relative_name(f, &self.includes))
952 .filter(|s| !s.is_empty())
953 .collect()
954 };
955
956 // Generate Rust source. Per-proto content files plus a per-package
957 // `.mod.rs` stitcher; only the stitchers need wiring into the
958 // module tree (content files are reached via `include!` from
959 // there).
960 let (generated, warnings) = buffa_codegen::generate_with_diagnostics(
961 &fds.file,
962 &files_to_generate,
963 &self.codegen_config,
964 )?;
965
966 // Surface non-fatal codegen diagnostics as Cargo build warnings. This
967 // runs inside the consumer's `build.rs`, so `cargo:warning=` is shown in
968 // their normal `cargo build` output.
969 for warning in warnings {
970 println!("cargo:warning=buffa: {warning}");
971 }
972
973 // Write output files; collect (name, package) for PackageMod entries.
974 let mut output_entries: Vec<(String, String)> = Vec::new();
975 for file in generated {
976 let path = out_dir.join(&file.name);
977 if let Some(parent) = path.parent() {
978 std::fs::create_dir_all(parent)?;
979 }
980 write_if_changed(&path, file.content.as_bytes())?;
981 if file.kind == buffa_codegen::GeneratedFileKind::PackageMod {
982 output_entries.push((file.name, file.package));
983 }
984 }
985
986 // Generate the include file if requested.
987 if let Some(ref include_name) = self.include_file {
988 let include_content = generate_include_file(&output_entries, relative_includes);
989 let include_path = out_dir.join(include_name);
990 write_if_changed(&include_path, include_content.as_bytes())?;
991 }
992
993 // Tell cargo to re-run if any proto file changes.
994 //
995 // For Buf mode, `self.files` are module-root-relative and cargo can't
996 // stat them — use `buf ls-files` instead, which lists all workspace
997 // protos with workspace-relative paths. This also catches changes to
998 // transitively-imported protos (a gap in the Protoc mode, which only
999 // watches explicitly-listed files).
1000 match self.descriptor_source {
1001 DescriptorSource::Buf => emit_buf_rerun_if_changed(),
1002 DescriptorSource::Protoc => {
1003 // Rerun if PROTOC changes (different binary may accept
1004 // protos the previous one rejected, e.g. newer editions).
1005 println!("cargo:rerun-if-env-changed=PROTOC");
1006 for proto_file in &self.files {
1007 println!("cargo:rerun-if-changed={}", proto_file.display());
1008 }
1009 }
1010 DescriptorSource::Precompiled(ref path) => {
1011 println!("cargo:rerun-if-changed={}", path.display());
1012 }
1013 }
1014
1015 Ok(())
1016 }
1017}
1018
1019impl Default for Config {
1020 fn default() -> Self {
1021 Self::new()
1022 }
1023}
1024
1025/// Normalize a user-supplied attribute-match path.
1026///
1027/// - Prepends `.` if absent so all stored paths are rooted.
1028/// - Trims trailing `.` so `".my.pkg."` and `".my.pkg"` behave identically
1029/// (trailing-dot patterns otherwise never match a real FQN).
1030/// - The bare catch-all `"."` is preserved as-is.
1031fn normalize_attr_path(mut path: String) -> String {
1032 if !path.starts_with('.') {
1033 path.insert(0, '.');
1034 }
1035 if path.len() > 1 {
1036 while path.ends_with('.') {
1037 path.pop();
1038 }
1039 }
1040 path
1041}
1042
1043/// Write `content` to `path` only if the file doesn't already exist with
1044/// identical content. Avoids bumping timestamps on unchanged files, which
1045/// prevents unnecessary downstream recompilation.
1046fn write_if_changed(path: &Path, content: &[u8]) -> std::io::Result<()> {
1047 if let Ok(existing) = std::fs::read(path) {
1048 if existing == content {
1049 return Ok(());
1050 }
1051 }
1052 std::fs::write(path, content)
1053}
1054
1055/// Invoke `protoc` to produce a `FileDescriptorSet` (serialized bytes).
1056fn invoke_protoc(
1057 files: &[PathBuf],
1058 includes: &[PathBuf],
1059) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
1060 let protoc = std::env::var("PROTOC").unwrap_or_else(|_| "protoc".to_string());
1061
1062 let descriptor_file =
1063 tempfile::NamedTempFile::new().map_err(|e| format!("failed to create temp file: {}", e))?;
1064 let descriptor_path = descriptor_file.path().to_path_buf();
1065
1066 let mut cmd = Command::new(&protoc);
1067 cmd.arg("--include_imports");
1068 cmd.arg("--include_source_info");
1069 cmd.arg(format!(
1070 "--descriptor_set_out={}",
1071 descriptor_path.display()
1072 ));
1073
1074 for include in includes {
1075 cmd.arg(format!("--proto_path={}", include.display()));
1076 }
1077
1078 for file in files {
1079 cmd.arg(file.as_os_str());
1080 }
1081
1082 let output = cmd
1083 .output()
1084 .map_err(|e| format!("failed to run protoc ({}): {}", protoc, e))?;
1085
1086 if !output.status.success() {
1087 let stderr = String::from_utf8_lossy(&output.stderr);
1088 return Err(format!("protoc failed: {}", stderr).into());
1089 }
1090
1091 let bytes = std::fs::read(&descriptor_path)
1092 .map_err(|e| format!("failed to read descriptor set: {}", e))?;
1093
1094 Ok(bytes)
1095}
1096
1097/// Invoke `buf build` to produce a `FileDescriptorSet` (serialized bytes).
1098///
1099/// Requires a `buf.yaml` discoverable from the build script's cwd. Builds
1100/// the entire workspace — no `--path` filtering, because buf's `--path` flag
1101/// expects workspace-relative paths while `FileDescriptorProto.name` is
1102/// module-root-relative; passing user paths to both would be a contradiction.
1103/// Codegen filtering happens on our side via `files_to_generate` matching.
1104fn invoke_buf() -> Result<Vec<u8>, Box<dyn std::error::Error>> {
1105 // buf build includes SourceCodeInfo by default (there's an
1106 // --exclude-source-info flag to disable it), so proto comments
1107 // propagate to generated code without an explicit opt-in here.
1108 let output = Command::new("buf")
1109 .arg("build")
1110 .arg("--as-file-descriptor-set")
1111 .arg("-o")
1112 .arg("-")
1113 .output()
1114 .map_err(|e| format!("failed to run buf (is it installed and on PATH?): {e}"))?;
1115
1116 if !output.status.success() {
1117 let stderr = String::from_utf8_lossy(&output.stderr);
1118 return Err(
1119 format!("buf build failed (is buf.yaml present at crate root?): {stderr}").into(),
1120 );
1121 }
1122
1123 Ok(output.stdout)
1124}
1125
1126/// Emit `cargo:rerun-if-changed` directives for a buf workspace.
1127///
1128/// Runs `buf ls-files` to discover all proto files with workspace-relative
1129/// paths (which cargo can stat). Also watches `buf.yaml` and `buf.lock`
1130/// (the latter only if it exists — cargo treats a missing rerun-if-changed
1131/// path as always-dirty). Failure is non-fatal: worst case cargo reruns
1132/// every build.
1133fn emit_buf_rerun_if_changed() {
1134 println!("cargo:rerun-if-changed=buf.yaml");
1135 if Path::new("buf.lock").exists() {
1136 println!("cargo:rerun-if-changed=buf.lock");
1137 }
1138 match Command::new("buf").arg("ls-files").output() {
1139 Ok(out) if out.status.success() => {
1140 for line in String::from_utf8_lossy(&out.stdout).lines() {
1141 let path = line.trim();
1142 if !path.is_empty() {
1143 println!("cargo:rerun-if-changed={path}");
1144 }
1145 }
1146 }
1147 _ => {
1148 // ls-files failed; cargo already knows about buf.yaml above.
1149 // If buf itself is missing, invoke_buf() will error clearly.
1150 }
1151 }
1152}
1153
1154/// Convert a filesystem proto path to the name protoc uses in the descriptor.
1155///
1156/// `FileDescriptorProto.name` is relative to the `--proto_path` include
1157/// directory. This strips the longest matching include prefix; if no include
1158/// matches, returns the path as-is (not just file_name — that would break
1159/// nested proto directories).
1160fn proto_relative_name(file: &Path, includes: &[PathBuf]) -> String {
1161 // Longest prefix wins: a file under both "proto/" and "proto/vendor/"
1162 // should strip "proto/vendor/" for a correct relative name.
1163 let mut best: Option<&Path> = None;
1164 for include in includes {
1165 if let Ok(rel) = file.strip_prefix(include) {
1166 match best {
1167 Some(prev) if prev.as_os_str().len() <= rel.as_os_str().len() => {}
1168 _ => best = Some(rel),
1169 }
1170 }
1171 }
1172 best.unwrap_or(file).to_str().unwrap_or("").to_string()
1173}
1174
1175/// Generate the content of an include file that assembles generated `.rs`
1176/// files into a nested module tree matching the protobuf package hierarchy.
1177///
1178/// Each generated file is named like `my.package.file_name.rs`. The package
1179/// segments become `pub mod` wrappers, and the file is `include!`d inside
1180/// the innermost module.
1181///
1182/// For example, files `["foo.bar.rs", "foo.baz.rs"]` produce:
1183/// ```text
1184/// pub mod foo {
1185/// #[allow(unused_imports)]
1186/// use super::*;
1187/// include!(concat!(env!("OUT_DIR"), "/foo.bar.rs"));
1188/// include!(concat!(env!("OUT_DIR"), "/foo.baz.rs"));
1189/// }
1190/// ```
1191///
1192/// When `relative` is true (the caller set [`Config::out_dir`] explicitly),
1193/// `include!` directives use bare sibling paths (`include!("foo.bar.rs")`)
1194/// instead of the `env!("OUT_DIR")` prefix, so the include file works when
1195/// checked into the source tree and referenced via `mod`.
1196fn generate_include_file(entries: &[(String, String)], relative: bool) -> String {
1197 let mode = if relative {
1198 buffa_codegen::IncludeMode::Relative("")
1199 } else {
1200 buffa_codegen::IncludeMode::OutDir
1201 };
1202 // Inner-allow off: this output is consumed via `include!` from
1203 // user-authored `lib.rs`, where `#![allow(...)]` is not valid.
1204 buffa_codegen::generate_module_tree(entries, mode, false)
1205}
1206
1207#[cfg(test)]
1208mod tests {
1209 use super::*;
1210
1211 #[test]
1212 fn proto_relative_name_strips_include() {
1213 let got = proto_relative_name(
1214 Path::new("proto/my/service.proto"),
1215 &[PathBuf::from("proto/")],
1216 );
1217 assert_eq!(got, "my/service.proto");
1218 }
1219
1220 #[test]
1221 fn proto_relative_name_longest_prefix_wins() {
1222 // Overlapping includes: file under both proto/ and proto/vendor/.
1223 // Must strip the LONGER prefix for the correct relative name.
1224 let got = proto_relative_name(
1225 Path::new("proto/vendor/ext.proto"),
1226 &[PathBuf::from("proto/"), PathBuf::from("proto/vendor/")],
1227 );
1228 assert_eq!(got, "ext.proto");
1229 // Same with reversed include order.
1230 let got = proto_relative_name(
1231 Path::new("proto/vendor/ext.proto"),
1232 &[PathBuf::from("proto/vendor/"), PathBuf::from("proto/")],
1233 );
1234 assert_eq!(got, "ext.proto");
1235 }
1236
1237 #[test]
1238 fn proto_relative_name_no_match_returns_full_path() {
1239 // Regression: previously fell back to file_name(), which stripped
1240 // directory components and broke descriptor_set() mode with nested
1241 // proto packages. Now returns the full path as-is.
1242 let got = proto_relative_name(Path::new("my/pkg/service.proto"), &[]);
1243 assert_eq!(got, "my/pkg/service.proto");
1244 }
1245
1246 #[test]
1247 fn proto_relative_name_no_match_with_unrelated_includes() {
1248 let got = proto_relative_name(
1249 Path::new("src/my.proto"),
1250 &[PathBuf::from("other/"), PathBuf::from("third/")],
1251 );
1252 assert_eq!(got, "src/my.proto");
1253 }
1254
1255 #[test]
1256 fn include_file_out_dir_mode_uses_env_var() {
1257 let entries = vec![
1258 ("foo.bar.rs".to_string(), "foo".to_string()),
1259 ("root.rs".to_string(), String::new()),
1260 ];
1261 let out = generate_include_file(&entries, false);
1262 assert!(
1263 out.contains(r#"include!(concat!(env!("OUT_DIR"), "/foo.bar.rs"));"#),
1264 "nested-package file should use env!(OUT_DIR): {out}"
1265 );
1266 assert!(
1267 out.contains(r#"include!(concat!(env!("OUT_DIR"), "/root.rs"));"#),
1268 "empty-package file should use env!(OUT_DIR): {out}"
1269 );
1270 assert!(!out.contains(r#"include!("foo.bar.rs")"#));
1271 }
1272
1273 #[test]
1274 fn include_file_relative_mode_uses_sibling_paths() {
1275 let entries = vec![
1276 ("foo.bar.rs".to_string(), "foo".to_string()),
1277 ("root.rs".to_string(), String::new()),
1278 ];
1279 let out = generate_include_file(&entries, true);
1280 assert!(
1281 out.contains(r#"include!("foo.bar.rs");"#),
1282 "nested-package file should use relative path: {out}"
1283 );
1284 assert!(
1285 out.contains(r#"include!("root.rs");"#),
1286 "empty-package file should use relative path: {out}"
1287 );
1288 assert!(
1289 !out.contains("OUT_DIR"),
1290 "relative mode must not reference OUT_DIR: {out}"
1291 );
1292 }
1293
1294 #[test]
1295 fn include_file_relative_mode_nested_packages() {
1296 // Two files in the same depth-2 package: verifies the relative flag
1297 // propagates through recursive emit() calls and both files land in
1298 // the same innermost mod.
1299 let entries = vec![
1300 ("a.b.one.rs".to_string(), "a.b".to_string()),
1301 ("a.b.two.rs".to_string(), "a.b".to_string()),
1302 ];
1303 let out = generate_include_file(&entries, true);
1304 // Both includes should appear once, at the same depth-2 indent,
1305 // inside a single `pub mod b { ... }`.
1306 let indent = " "; // depth 2 = 8 spaces
1307 assert!(
1308 out.contains(&format!(r#"{indent}include!("a.b.one.rs");"#)),
1309 "first file at depth 2: {out}"
1310 );
1311 assert!(
1312 out.contains(&format!(r#"{indent}include!("a.b.two.rs");"#)),
1313 "second file at depth 2: {out}"
1314 );
1315 assert_eq!(
1316 out.matches("pub mod b {").count(),
1317 1,
1318 "both files share one `mod b`: {out}"
1319 );
1320 assert!(!out.contains("OUT_DIR"));
1321 }
1322
1323 #[test]
1324 fn write_if_changed_creates_new_file() {
1325 let dir = tempfile::tempdir().unwrap();
1326 let path = dir.path().join("new.rs");
1327 write_if_changed(&path, b"hello").unwrap();
1328 assert_eq!(std::fs::read(&path).unwrap(), b"hello");
1329 }
1330
1331 #[test]
1332 fn write_if_changed_skips_identical_content() {
1333 let dir = tempfile::tempdir().unwrap();
1334 let path = dir.path().join("same.rs");
1335 std::fs::write(&path, b"content").unwrap();
1336 let mtime_before = std::fs::metadata(&path).unwrap().modified().unwrap();
1337
1338 // Sleep briefly so any write would produce a different mtime.
1339 std::thread::sleep(std::time::Duration::from_millis(50));
1340
1341 write_if_changed(&path, b"content").unwrap();
1342 let mtime_after = std::fs::metadata(&path).unwrap().modified().unwrap();
1343 assert_eq!(mtime_before, mtime_after);
1344 }
1345
1346 #[test]
1347 fn write_if_changed_overwrites_different_content() {
1348 let dir = tempfile::tempdir().unwrap();
1349 let path = dir.path().join("changed.rs");
1350 std::fs::write(&path, b"old").unwrap();
1351
1352 write_if_changed(&path, b"new").unwrap();
1353 assert_eq!(std::fs::read(&path).unwrap(), b"new");
1354 }
1355
1356 #[test]
1357 fn normalize_attr_path_prepends_leading_dot() {
1358 assert_eq!(normalize_attr_path("my.pkg".into()), ".my.pkg");
1359 }
1360
1361 #[test]
1362 fn normalize_attr_path_preserves_leading_dot() {
1363 assert_eq!(normalize_attr_path(".my.pkg".into()), ".my.pkg");
1364 }
1365
1366 #[test]
1367 fn normalize_attr_path_trims_trailing_dot() {
1368 assert_eq!(normalize_attr_path("my.pkg.".into()), ".my.pkg");
1369 assert_eq!(normalize_attr_path(".my.pkg.".into()), ".my.pkg");
1370 assert_eq!(normalize_attr_path(".my.pkg...".into()), ".my.pkg");
1371 }
1372
1373 #[test]
1374 fn normalize_attr_path_preserves_catchall() {
1375 assert_eq!(normalize_attr_path(".".into()), ".");
1376 assert_eq!(normalize_attr_path("".into()), ".");
1377 }
1378
1379 #[test]
1380 fn type_attribute_forwards_normalized_path() {
1381 let cfg = Config::new().type_attribute("my.pkg.", "#[derive(Foo)]");
1382 assert_eq!(
1383 cfg.codegen_config.type_attributes,
1384 vec![(".my.pkg".to_string(), "#[derive(Foo)]".to_string())]
1385 );
1386 }
1387
1388 #[test]
1389 fn field_attribute_forwards_normalized_path() {
1390 let cfg = Config::new().field_attribute("pkg.Msg.f", "#[serde(skip)]");
1391 assert_eq!(
1392 cfg.codegen_config.field_attributes,
1393 vec![(".pkg.Msg.f".to_string(), "#[serde(skip)]".to_string())]
1394 );
1395 }
1396
1397 #[test]
1398 fn message_attribute_forwards_normalized_path() {
1399 let cfg = Config::new().message_attribute(".", "#[serde(default)]");
1400 assert_eq!(
1401 cfg.codegen_config.message_attributes,
1402 vec![(".".to_string(), "#[serde(default)]".to_string())]
1403 );
1404 }
1405
1406 #[test]
1407 fn enum_attribute_forwards_normalized_path() {
1408 let cfg = Config::new().enum_attribute("my.pkg.", "#[derive(strum::EnumIter)]");
1409 assert_eq!(
1410 cfg.codegen_config.enum_attributes,
1411 vec![(
1412 ".my.pkg".to_string(),
1413 "#[derive(strum::EnumIter)]".to_string(),
1414 )]
1415 );
1416 // Other attribute lists must remain untouched.
1417 assert!(cfg.codegen_config.type_attributes.is_empty());
1418 assert!(cfg.codegen_config.message_attributes.is_empty());
1419 assert!(cfg.codegen_config.field_attributes.is_empty());
1420 }
1421
1422 #[test]
1423 fn attribute_calls_accumulate_in_insertion_order() {
1424 let cfg = Config::new()
1425 .type_attribute(".", "#[derive(A)]")
1426 .type_attribute(".pkg.M", "#[derive(B)]")
1427 .type_attribute(".", "#[derive(C)]");
1428 let paths: Vec<_> = cfg
1429 .codegen_config
1430 .type_attributes
1431 .iter()
1432 .map(|(_, a)| a.as_str())
1433 .collect();
1434 assert_eq!(paths, vec!["#[derive(A)]", "#[derive(B)]", "#[derive(C)]"]);
1435 }
1436}