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