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