Skip to main content

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}