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