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}