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    /// # 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}