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