Skip to main content

buffa_reflect_build/
builder.rs

1//! Builder API and implementation for `buffa-reflect-build`.
2
3use std::{
4    ffi::OsString,
5    path::{Path, PathBuf},
6    process::Command,
7};
8
9use buffa::Message as _;
10use buffa_descriptor::generated::descriptor::{DescriptorProto, FileDescriptorSet};
11
12/// Default name for the descriptor-set artifact written into `OUT_DIR`.
13const DEFAULT_FDS_NAME: &str = "file_descriptor_set.bin";
14
15/// How to obtain a `FileDescriptorSet` to feed both buffa-build and the
16/// reflection layer.
17#[derive(Debug, Clone, Default)]
18enum DescriptorSource {
19    /// Run `protoc --include_imports --include_source_info` ourselves and
20    /// hand the resulting binary to buffa-build via `descriptor_set`.
21    #[default]
22    Protoc,
23    /// Run `buf build --as-file-descriptor-set -o <path>`.
24    Buf,
25    /// Re-use a pre-built `FileDescriptorSet` binary.
26    Precompiled(PathBuf),
27}
28
29/// Builder for compiling `.proto` files with reflection metadata enabled.
30///
31/// Driving the build script:
32///
33/// ```rust,ignore
34/// fn main() -> Result<(), Box<dyn std::error::Error>> {
35///     buffa_reflect_build::Builder::new()
36///         .file_descriptor_set_bytes("crate::FILE_DESCRIPTOR_SET_BYTES")
37///         .files(&["proto/acme/api/v1/user.proto"])
38///         .includes(&["proto/"])
39///         .compile()?;
40///     Ok(())
41/// }
42/// ```
43///
44/// The downstream library then ships:
45///
46/// ```rust,ignore
47/// pub const FILE_DESCRIPTOR_SET_BYTES: &[u8] =
48///     include_bytes!(concat!(env!("OUT_DIR"), "/file_descriptor_set.bin"));
49///
50/// buffa::include_proto!("acme.api.v1");
51/// ```
52///
53/// Every generated message is decorated with
54/// `#[derive(::buffa_reflect::ReflectMessage)]` so that
55/// `m.descriptor()` resolves the right `MessageDescriptor` at runtime.
56#[derive(Debug, Default)]
57pub struct Builder {
58    descriptor_source: DescriptorSource,
59    files: Vec<PathBuf>,
60    includes: Vec<PathBuf>,
61    out_dir: Option<PathBuf>,
62    file_descriptor_set_path: Option<PathBuf>,
63    descriptor_pool_expr: Option<String>,
64    file_descriptor_set_bytes_expr: Option<String>,
65    include_file: Option<String>,
66
67    // user-supplied codegen passthroughs
68    type_attributes: Vec<(String, String)>,
69    field_attributes: Vec<(String, String)>,
70    message_attributes: Vec<(String, String)>,
71    enum_attributes: Vec<(String, String)>,
72    extern_paths: Vec<(String, String)>,
73    bytes_paths: Vec<String>,
74    generate_views: Option<bool>,
75    generate_json: Option<bool>,
76    generate_text: Option<bool>,
77    generate_arbitrary: Option<bool>,
78    preserve_unknown_fields: Option<bool>,
79    strict_utf8_mapping: Option<bool>,
80    allow_message_set: Option<bool>,
81    generate_view_reflection: Option<bool>,
82    view_reflection_include_file: Option<String>,
83    view_reflection_root_path: Option<String>,
84}
85
86/// Errors raised by [`Builder::compile`].
87#[derive(Debug, thiserror::Error)]
88#[non_exhaustive]
89pub enum Error {
90    /// Neither [`Builder::descriptor_pool`] nor
91    /// [`Builder::file_descriptor_set_bytes`] was supplied.
92    #[error(
93        "buffa-reflect-build: descriptor binding not configured — call `.descriptor_pool(..)` or \
94         `.file_descriptor_set_bytes(..)`"
95    )]
96    MissingDescriptorBinding,
97
98    /// Both bindings were supplied; the macro accepts at most one.
99    #[error(
100        "buffa-reflect-build: cannot set both `descriptor_pool` and `file_descriptor_set_bytes` — \
101         pick one"
102    )]
103    ConflictingDescriptorBindings,
104
105    /// `OUT_DIR` was not set by cargo and no explicit
106    /// [`Builder::out_dir`] was given.
107    #[error(
108        "buffa-reflect-build: OUT_DIR is not set and no out_dir() was configured (run from \
109         build.rs or call .out_dir())"
110    )]
111    MissingOutDir,
112
113    /// Spawning the descriptor-producing tool (`protoc`/`buf`) failed.
114    #[error("buffa-reflect-build: failed to invoke {tool}: {source}")]
115    DescriptorTool {
116        /// The tool that failed to launch.
117        tool: &'static str,
118        /// Source `io::Error`.
119        #[source]
120        source: std::io::Error,
121    },
122
123    /// The descriptor-producing tool returned a non-zero exit status.
124    #[error("buffa-reflect-build: {tool} exited with status {status}: {stderr}")]
125    DescriptorToolFailed {
126        /// The tool that failed.
127        tool: &'static str,
128        /// Exit status returned by the tool.
129        status: std::process::ExitStatus,
130        /// Captured stderr.
131        stderr: String,
132    },
133
134    /// The descriptor-set bytes could not be parsed.
135    #[error("buffa-reflect-build: failed to decode FileDescriptorSet: {0}")]
136    DecodeFileDescriptorSet(#[source] buffa::DecodeError),
137
138    /// `Buf` mode resolves imports through `buf.yaml`/`buf.work.yaml` and
139    /// has no use for `protoc`-style `.includes(..)`. Configuring both is
140    /// almost always a bug, so we error rather than silently dropping the
141    /// include list.
142    #[error(
143        "buffa-reflect-build: .includes(..) is not supported in `use_buf` mode (buf reads import \
144         paths from buf.yaml); drop the includes() call or switch to the protoc source"
145    )]
146    BufWithIncludes,
147
148    /// `buffa-build` reported an error.
149    ///
150    /// `buffa-build`'s `compile()` returns a `Box<dyn Error>` (without
151    /// `Send + Sync`), so we capture its rendered message and surface it
152    /// here as a string. The original error chain is unfortunately lost on
153    /// the `buffa-build` side; if more structured failure data is needed
154    /// we can lift it from a future `buffa-build` release.
155    #[error("buffa-reflect-build: buffa-build error: {0}")]
156    BuffaBuild(String),
157
158    /// I/O error while reading or writing artifacts.
159    #[error("buffa-reflect-build: io error: {0}")]
160    Io(#[from] std::io::Error),
161}
162
163impl Builder {
164    /// Construct a new builder with default settings.
165    #[must_use]
166    pub fn new() -> Self {
167        Self::default()
168    }
169
170    /// Use a user-managed `DescriptorPool` for runtime descriptor lookup.
171    /// The argument is a Rust expression yielding
172    /// `&buffa_reflect::DescriptorPool` (e.g. `"crate::DESCRIPTOR_POOL"`).
173    #[must_use]
174    pub fn descriptor_pool(mut self, expr: impl Into<String>) -> Self {
175        self.descriptor_pool_expr = Some(expr.into());
176        self
177    }
178
179    /// Embed the descriptor-set bytes directly. The argument is a Rust
180    /// expression yielding `&[u8]`, typically
181    /// `"crate::FILE_DESCRIPTOR_SET_BYTES"` paired with
182    /// `include_bytes!(concat!(env!("OUT_DIR"), "/file_descriptor_set.bin"))`.
183    #[must_use]
184    pub fn file_descriptor_set_bytes(mut self, expr: impl Into<String>) -> Self {
185        self.file_descriptor_set_bytes_expr = Some(expr.into());
186        self
187    }
188
189    /// Override where the descriptor-set artifact is written. Defaults to
190    /// `<OUT_DIR>/file_descriptor_set.bin`.
191    #[must_use]
192    pub fn file_descriptor_set_path(mut self, path: impl Into<PathBuf>) -> Self {
193        self.file_descriptor_set_path = Some(path.into());
194        self
195    }
196
197    /// `.proto` files to compile. Same semantics as
198    /// [`buffa_build::Config::files`].
199    #[must_use]
200    pub fn files(mut self, files: &[impl AsRef<Path>]) -> Self {
201        self.files
202            .extend(files.iter().map(|f| f.as_ref().to_path_buf()));
203        self
204    }
205
206    /// Include directories searched by protoc.
207    #[must_use]
208    pub fn includes(mut self, includes: &[impl AsRef<Path>]) -> Self {
209        self.includes
210            .extend(includes.iter().map(|i| i.as_ref().to_path_buf()));
211        self
212    }
213
214    /// Use `buf build --as-file-descriptor-set` instead of `protoc`.
215    #[must_use]
216    pub fn use_buf(mut self) -> Self {
217        self.descriptor_source = DescriptorSource::Buf;
218        self
219    }
220
221    /// Use a precompiled descriptor-set file. Skips invoking `protoc`/`buf`.
222    /// The file must contain a serialized
223    /// `google.protobuf.FileDescriptorSet`.
224    #[must_use]
225    pub fn descriptor_set(mut self, path: impl Into<PathBuf>) -> Self {
226        self.descriptor_source = DescriptorSource::Precompiled(path.into());
227        self
228    }
229
230    /// Override the output directory (defaults to `$OUT_DIR`).
231    #[must_use]
232    pub fn out_dir(mut self, dir: impl Into<PathBuf>) -> Self {
233        self.out_dir = Some(dir.into());
234        self
235    }
236
237    /// Pass a `type_attribute` through to buffa-build.
238    #[must_use]
239    pub fn type_attribute(mut self, path: impl Into<String>, attr: impl Into<String>) -> Self {
240        self.type_attributes.push((path.into(), attr.into()));
241        self
242    }
243
244    /// Pass a `field_attribute` through to buffa-build.
245    #[must_use]
246    pub fn field_attribute(mut self, path: impl Into<String>, attr: impl Into<String>) -> Self {
247        self.field_attributes.push((path.into(), attr.into()));
248        self
249    }
250
251    /// Pass a `message_attribute` through to buffa-build (struct-only).
252    #[must_use]
253    pub fn message_attribute(mut self, path: impl Into<String>, attr: impl Into<String>) -> Self {
254        self.message_attributes.push((path.into(), attr.into()));
255        self
256    }
257
258    /// Pass an `enum_attribute` through to buffa-build (enum-only).
259    #[must_use]
260    pub fn enum_attribute(mut self, path: impl Into<String>, attr: impl Into<String>) -> Self {
261        self.enum_attributes.push((path.into(), attr.into()));
262        self
263    }
264
265    /// Map a proto path prefix to an external Rust module. Forwarded to
266    /// [`buffa_build::Config::extern_path`].
267    #[must_use]
268    pub fn extern_path(
269        mut self,
270        proto_path: impl Into<String>,
271        rust_path: impl Into<String>,
272    ) -> Self {
273        self.extern_paths
274            .push((proto_path.into(), rust_path.into()));
275        self
276    }
277
278    /// Mark `bytes` fields under the given proto-path prefixes as
279    /// `bytes::Bytes`-typed.
280    #[must_use]
281    pub fn use_bytes_type_in(mut self, paths: &[impl AsRef<str>]) -> Self {
282        self.bytes_paths
283            .extend(paths.iter().map(|p| p.as_ref().to_string()));
284        self
285    }
286
287    /// Toggle generation of borrowed view types. Defaults to buffa-build's
288    /// own default (currently `true`).
289    #[must_use]
290    pub fn generate_views(mut self, enabled: bool) -> Self {
291        self.generate_views = Some(enabled);
292        self
293    }
294
295    /// Toggle proto3 JSON serde derives.
296    #[must_use]
297    pub fn generate_json(mut self, enabled: bool) -> Self {
298        self.generate_json = Some(enabled);
299        self
300    }
301
302    /// Toggle textproto support.
303    #[must_use]
304    pub fn generate_text(mut self, enabled: bool) -> Self {
305        self.generate_text = Some(enabled);
306        self
307    }
308
309    /// Toggle `arbitrary::Arbitrary` derives.
310    #[must_use]
311    pub fn generate_arbitrary(mut self, enabled: bool) -> Self {
312        self.generate_arbitrary = Some(enabled);
313        self
314    }
315
316    /// Toggle unknown-field preservation.
317    #[must_use]
318    pub fn preserve_unknown_fields(mut self, enabled: bool) -> Self {
319        self.preserve_unknown_fields = Some(enabled);
320        self
321    }
322
323    /// Honor `features.utf8_validation = NONE` by mapping such strings to
324    /// bytes — see [`buffa_build::Config::strict_utf8_mapping`].
325    #[must_use]
326    pub fn strict_utf8_mapping(mut self, enabled: bool) -> Self {
327        self.strict_utf8_mapping = Some(enabled);
328        self
329    }
330
331    /// Permit `message_set_wire_format = true` on input messages.
332    #[must_use]
333    pub fn allow_message_set(mut self, enabled: bool) -> Self {
334        self.allow_message_set = Some(enabled);
335        self
336    }
337
338    /// Emit a per-package include-file alongside the per-proto outputs.
339    #[must_use]
340    pub fn include_file(mut self, name: impl Into<String>) -> Self {
341        self.include_file = Some(name.into());
342        self
343    }
344
345    /// Emit `impl ReflectMessageView<'a>` blocks for every generated
346    /// view type into `OUT_DIR/<view_reflection_include_file>`. The
347    /// downstream user `include!`s the file once after their
348    /// `buffa::include_proto!(...)` invocation.
349    ///
350    /// Default: `true` when the `file_descriptor_set_bytes` binding
351    /// is configured (the only binding the emitter can plumb into a
352    /// `OnceLock` initializer); `false` otherwise. Override
353    /// explicitly to opt out.
354    #[must_use]
355    pub fn generate_view_reflection(mut self, enabled: bool) -> Self {
356        self.generate_view_reflection = Some(enabled);
357        self
358    }
359
360    /// Override the file name of the view-reflection include file.
361    /// Default: `_reflect_views.rs`.
362    #[must_use]
363    pub fn view_reflection_include_file(mut self, name: impl Into<String>) -> Self {
364        self.view_reflection_include_file = Some(name.into());
365        self
366    }
367
368    /// Override the Rust module path under which the generated view
369    /// types live. Default: `crate::__buffa::view`, matching the
370    /// `buffa::include_proto!` invocation at the crate root.
371    ///
372    /// Override when the consumer wraps `include_proto!` inside a
373    /// non-root module, e.g. `pub mod my_pkg { buffa::include_proto!(...); }`
374    /// — set this to `crate::my_pkg::__buffa::view`.
375    #[must_use]
376    pub fn view_reflection_root_path(mut self, path: impl Into<String>) -> Self {
377        self.view_reflection_root_path = Some(path.into());
378        self
379    }
380
381    /// Compile the configured `.proto` files.
382    ///
383    /// # Errors
384    ///
385    /// See [`Error`] for the failure modes — most user errors are an
386    /// unconfigured descriptor binding, a missing `OUT_DIR`, or
387    /// `protoc`/`buf` invocation failure.
388    pub fn compile(self) -> Result<(), Error> {
389        match (
390            self.descriptor_pool_expr.is_some(),
391            self.file_descriptor_set_bytes_expr.is_some(),
392        ) {
393            (false, false) => return Err(Error::MissingDescriptorBinding),
394            (true, true) => return Err(Error::ConflictingDescriptorBindings),
395            _ => {}
396        }
397
398        if matches!(self.descriptor_source, DescriptorSource::Buf) && !self.includes.is_empty() {
399            return Err(Error::BufWithIncludes);
400        }
401
402        let out_dir = self
403            .out_dir
404            .clone()
405            .or_else(|| std::env::var_os("OUT_DIR").map(PathBuf::from))
406            .ok_or(Error::MissingOutDir)?;
407        std::fs::create_dir_all(&out_dir)?;
408
409        let fds_path = self
410            .file_descriptor_set_path
411            .clone()
412            .unwrap_or_else(|| out_dir.join(DEFAULT_FDS_NAME));
413        if let Some(parent) = fds_path.parent() {
414            std::fs::create_dir_all(parent)?;
415        }
416
417        produce_descriptor_set(
418            &self.descriptor_source,
419            &self.files,
420            &self.includes,
421            &fds_path,
422        )?;
423
424        let fds_bytes = std::fs::read(&fds_path)?;
425        let fds = FileDescriptorSet::decode_from_slice(&fds_bytes)
426            .map_err(Error::DecodeFileDescriptorSet)?;
427
428        // Emit reflection-attribute decorations.
429        let mut cfg = buffa_build::Config::new();
430        cfg = self.apply_reflection_attributes(cfg, &fds);
431        cfg = self.apply_user_passthroughs(cfg);
432
433        // The `Builder::compile` algorithm always feeds buffa-build via the
434        // precompiled descriptor set we just wrote, regardless of how it
435        // was obtained — that's the seam that lets us own the FDS bytes.
436        cfg = cfg.descriptor_set(&fds_path);
437        cfg = cfg.files(&proto_relative_files(
438            &self.descriptor_source,
439            &self.files,
440            &self.includes,
441            &fds,
442        ));
443        cfg = cfg.out_dir(&out_dir);
444        if let Some(name) = &self.include_file {
445            cfg = cfg.include_file(name);
446        }
447
448        cfg.compile()
449            .map_err(|e| Error::BuffaBuild(e.to_string()))?;
450
451        let want_view_refl = self
452            .generate_view_reflection
453            .unwrap_or(self.file_descriptor_set_bytes_expr.is_some());
454        if want_view_refl {
455            emit_view_reflection_file(&self, &fds, &out_dir)?;
456        }
457
458        // Cargo dependency tracking. buffa-build's own emit only fires when
459        // its descriptor source matches the cargo trigger style; since we
460        // always feed it Precompiled, we re-emit the right ones ourselves.
461        emit_cargo_directives(&self.descriptor_source, &self.files, &fds_path);
462
463        Ok(())
464    }
465
466    fn apply_reflection_attributes(
467        &self,
468        mut cfg: buffa_build::Config,
469        fds: &FileDescriptorSet,
470    ) -> buffa_build::Config {
471        let binding_attr = if let Some(expr) = &self.descriptor_pool_expr {
472            format!(r#"#[buffa_reflect(descriptor_pool = "{expr}")]"#)
473        } else if let Some(expr) = &self.file_descriptor_set_bytes_expr {
474            format!(r#"#[buffa_reflect(file_descriptor_set_bytes = "{expr}")]"#)
475        } else {
476            unreachable!("descriptor binding presence enforced above")
477        };
478
479        // One ReflectMessage derive + one binding attribute on every
480        // generated message struct. `message_attribute(".", ...)` lands on
481        // every message exactly once and avoids decorating enums.
482        cfg = cfg.message_attribute(".", "#[derive(::buffa_reflect::ReflectMessage)]");
483        cfg = cfg.message_attribute(".", binding_attr);
484
485        // Per-message FQN attribute. The codegen prefix-match means nested
486        // messages also pick up parent attributes; the derive picks the
487        // longest message_name to disambiguate.
488        for full_name in collect_message_full_names(fds) {
489            cfg = cfg.message_attribute(
490                format!(".{full_name}"),
491                format!(r#"#[buffa_reflect(message_name = "{full_name}")]"#),
492            );
493        }
494        cfg
495    }
496
497    fn apply_user_passthroughs(&self, mut cfg: buffa_build::Config) -> buffa_build::Config {
498        for (path, attr) in &self.type_attributes {
499            cfg = cfg.type_attribute(path, attr);
500        }
501        for (path, attr) in &self.field_attributes {
502            cfg = cfg.field_attribute(path, attr);
503        }
504        for (path, attr) in &self.message_attributes {
505            cfg = cfg.message_attribute(path, attr);
506        }
507        for (path, attr) in &self.enum_attributes {
508            cfg = cfg.enum_attribute(path, attr);
509        }
510        for (proto, rust) in &self.extern_paths {
511            cfg = cfg.extern_path(proto.clone(), rust.clone());
512        }
513        if !self.bytes_paths.is_empty() {
514            cfg = cfg.use_bytes_type_in(&self.bytes_paths);
515        }
516        if let Some(b) = self.generate_views {
517            cfg = cfg.generate_views(b);
518        }
519        if let Some(b) = self.generate_json {
520            cfg = cfg.generate_json(b);
521        }
522        if let Some(b) = self.generate_text {
523            cfg = cfg.generate_text(b);
524        }
525        if let Some(b) = self.generate_arbitrary {
526            cfg = cfg.generate_arbitrary(b);
527        }
528        if let Some(b) = self.preserve_unknown_fields {
529            cfg = cfg.preserve_unknown_fields(b);
530        }
531        if let Some(b) = self.strict_utf8_mapping {
532            cfg = cfg.strict_utf8_mapping(b);
533        }
534        if let Some(b) = self.allow_message_set {
535            cfg = cfg.allow_message_set(b);
536        }
537        cfg
538    }
539}
540
541fn produce_descriptor_set(
542    source: &DescriptorSource,
543    files: &[PathBuf],
544    includes: &[PathBuf],
545    fds_path: &Path,
546) -> Result<(), Error> {
547    match source {
548        DescriptorSource::Protoc => invoke_protoc(files, includes, fds_path),
549        DescriptorSource::Buf => invoke_buf(fds_path),
550        DescriptorSource::Precompiled(src) => {
551            std::fs::copy(src, fds_path).map(|_| ()).map_err(Error::Io)
552        }
553    }
554}
555
556fn invoke_protoc(files: &[PathBuf], includes: &[PathBuf], out: &Path) -> Result<(), Error> {
557    let protoc: OsString = std::env::var_os("PROTOC").unwrap_or_else(|| "protoc".into());
558    let mut cmd = Command::new(&protoc);
559    cmd.arg("--include_imports");
560    cmd.arg("--include_source_info");
561    {
562        let mut arg = OsString::from("--descriptor_set_out=");
563        arg.push(out);
564        cmd.arg(arg);
565    }
566    for include in includes {
567        let mut arg = OsString::from("--proto_path=");
568        arg.push(include);
569        cmd.arg(arg);
570    }
571    for file in files {
572        cmd.arg(file);
573    }
574    let output = cmd.output().map_err(|source| Error::DescriptorTool {
575        tool: "protoc",
576        source,
577    })?;
578    if !output.status.success() {
579        return Err(Error::DescriptorToolFailed {
580            tool: "protoc",
581            status: output.status,
582            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
583        });
584    }
585    Ok(())
586}
587
588fn invoke_buf(out: &Path) -> Result<(), Error> {
589    let mut cmd = Command::new("buf");
590    cmd.arg("build")
591        .arg("--as-file-descriptor-set")
592        .arg("-o")
593        .arg(out);
594    let output = cmd.output().map_err(|source| Error::DescriptorTool {
595        tool: "buf",
596        source,
597    })?;
598    if !output.status.success() {
599        return Err(Error::DescriptorToolFailed {
600            tool: "buf",
601            status: output.status,
602            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
603        });
604    }
605    Ok(())
606}
607
608fn proto_relative_files(
609    source: &DescriptorSource,
610    files: &[PathBuf],
611    includes: &[PathBuf],
612    fds: &FileDescriptorSet,
613) -> Vec<String> {
614    match source {
615        DescriptorSource::Buf | DescriptorSource::Precompiled(_) => {
616            // `.files()` already names entries; pass through.
617            // For Precompiled with no `.files(...)` set, fall back to
618            // every file in the FDS.
619            if files.is_empty() {
620                fds.file.iter().filter_map(|f| f.name.clone()).collect()
621            } else {
622                files
623                    .iter()
624                    .map(|p| p.to_string_lossy().into_owned())
625                    .collect()
626            }
627        }
628        DescriptorSource::Protoc => files
629            .iter()
630            .map(|f| proto_relative_name(f, includes))
631            .filter(|s| !s.is_empty())
632            .collect(),
633    }
634}
635
636/// Strip the longest matching include prefix off `file`, mirroring
637/// `buffa-build`'s own behavior.
638fn proto_relative_name(file: &Path, includes: &[PathBuf]) -> String {
639    // Longest matched prefix wins → produce the SHORTEST relative path.
640    let mut best: Option<&Path> = None;
641    for include in includes {
642        if let Ok(rel) = file.strip_prefix(include) {
643            match best {
644                Some(prev) if prev.as_os_str().len() <= rel.as_os_str().len() => {}
645                _ => best = Some(rel),
646            }
647        }
648    }
649    best.unwrap_or(file).to_string_lossy().into_owned()
650}
651
652/// Emit `OUT_DIR/<view_reflection_include_file>` with one
653/// `impl ReflectMessageView<'a>` block per generated view type.
654fn emit_view_reflection_file(
655    builder: &Builder,
656    fds: &FileDescriptorSet,
657    out_dir: &Path,
658) -> Result<(), Error> {
659    let file_name = builder
660        .view_reflection_include_file
661        .as_deref()
662        .unwrap_or("_reflect_views.rs");
663    let view_root = builder
664        .view_reflection_root_path
665        .as_deref()
666        .unwrap_or("crate::__buffa::view");
667    let bytes_expr = builder
668        .file_descriptor_set_bytes_expr
669        .as_deref()
670        .ok_or_else(|| {
671            Error::BuffaBuild(
672                "view-reflection emit requires file_descriptor_set_bytes(\"...\") binding".into(),
673            )
674        })?;
675
676    let mut out = String::new();
677    out.push_str(&format!(
678        "// @generated by buffa-reflect-build {} for view reflection. DO NOT EDIT.\n// View \
679         module convention pinned to buffa-codegen as of buffa 0.4.x.\n// If buffa changes its \
680         __buffa::view module layout, regenerate.\n",
681        env!("CARGO_PKG_VERSION"),
682    ));
683
684    for entry in collect_view_entries(fds) {
685        let view_path = format!(
686            "{view_root}::{}{}View<'__a>",
687            entry.module_path, entry.struct_name
688        );
689        let fqn = entry.full_name;
690        out.push_str(&format!(
691            r#"
692impl<'__a> ::buffa_reflect::ReflectMessageView<'__a> for {view_path} {{
693    fn descriptor(&self) -> ::buffa_reflect::MessageDescriptor {{
694        static __INIT: ::std::sync::OnceLock<::buffa_reflect::DescriptorPool> =
695            ::std::sync::OnceLock::new();
696        let pool = __INIT.get_or_init(|| {{
697            ::buffa_reflect::DescriptorPool::decode({bytes_expr})
698                .expect("buffa-reflect: invalid FileDescriptorSet")
699        }});
700        pool.get_message_by_name("{fqn}")
701            .expect(concat!("descriptor for `", "{fqn}", "` not found"))
702    }}
703}}
704"#,
705        ));
706    }
707
708    let path = out_dir.join(file_name);
709    std::fs::write(&path, out)?;
710    Ok(())
711}
712
713/// Per-message info needed to emit a view-reflection impl.
714struct ViewEntry {
715    /// Fully-qualified protobuf name (e.g. `acme.api.v1.Library.Book`).
716    full_name: String,
717    /// Rust module-path relative to the view root (`library::` for a
718    /// nested message, empty for top-level).
719    module_path: String,
720    /// Rust struct base name (the `View` suffix is appended in the impl).
721    struct_name: String,
722}
723
724fn collect_view_entries(fds: &FileDescriptorSet) -> Vec<ViewEntry> {
725    let mut out = Vec::new();
726    for file in &fds.file {
727        let pkg = file.package.as_deref().unwrap_or("");
728        for msg in &file.message_type {
729            collect_view_entries_inner(msg, pkg, "", &mut out);
730        }
731    }
732    out
733}
734
735fn collect_view_entries_inner(
736    msg: &DescriptorProto,
737    proto_scope: &str,
738    rust_scope: &str,
739    out: &mut Vec<ViewEntry>,
740) {
741    let Some(name) = msg.name.as_deref() else {
742        return;
743    };
744    let full_name = if proto_scope.is_empty() {
745        name.to_string()
746    } else {
747        format!("{proto_scope}.{name}")
748    };
749    let is_map_entry = msg
750        .options
751        .as_option()
752        .and_then(|o| o.map_entry)
753        .unwrap_or(false);
754    if !is_map_entry {
755        out.push(ViewEntry {
756            full_name: full_name.clone(),
757            module_path: rust_scope.to_string(),
758            struct_name: name.to_string(),
759        });
760    }
761    if !msg.nested_type.is_empty() {
762        let child_rust_scope = format!("{rust_scope}{}::", to_snake_case(name));
763        for nested in &msg.nested_type {
764            collect_view_entries_inner(nested, &full_name, &child_rust_scope, out);
765        }
766    }
767}
768
769/// Lifted verbatim from `buffa-codegen::oneof::to_snake_case` so the
770/// generated view-module path mirrors what buffa-codegen emits. Kept
771/// in sync manually; if buffa changes the convention, the
772/// version-pinned header in the emitted file flags the mismatch.
773fn to_snake_case(s: &str) -> String {
774    let mut result = String::with_capacity(s.len() + 4);
775    let chars: Vec<char> = s.chars().collect();
776    for (i, &c) in chars.iter().enumerate() {
777        if c.is_uppercase() && i > 0 {
778            let prev = chars[i - 1];
779            let next_is_lower = chars.get(i + 1).is_some_and(|n| n.is_lowercase());
780            if prev.is_lowercase() || (prev.is_uppercase() && next_is_lower) {
781                result.push('_');
782            }
783        }
784        result.push(c.to_lowercase().next().unwrap());
785    }
786    result
787}
788
789/// Walk `fds` and return every message FQN, including nested messages and
790/// synthetic map-entry messages (the latter just don't have a Rust struct,
791/// so the attribute is harmlessly dropped).
792fn collect_message_full_names(fds: &FileDescriptorSet) -> Vec<String> {
793    let mut out = Vec::new();
794    for file in &fds.file {
795        let pkg = file.package.as_deref().unwrap_or("");
796        for msg in &file.message_type {
797            collect_messages(msg, pkg, &mut out);
798        }
799    }
800    out
801}
802
803fn collect_messages(msg: &DescriptorProto, scope: &str, out: &mut Vec<String>) {
804    let Some(name) = msg.name.as_deref() else {
805        return;
806    };
807    let full_name = if scope.is_empty() {
808        name.to_string()
809    } else {
810        format!("{scope}.{name}")
811    };
812    out.push(full_name.clone());
813    for nested in &msg.nested_type {
814        collect_messages(nested, &full_name, out);
815    }
816}
817
818fn emit_cargo_directives(source: &DescriptorSource, files: &[PathBuf], fds_path: &Path) {
819    println!("cargo:rerun-if-changed={}", fds_path.display());
820    match source {
821        DescriptorSource::Protoc => {
822            println!("cargo:rerun-if-env-changed=PROTOC");
823            for f in files {
824                println!("cargo:rerun-if-changed={}", f.display());
825            }
826        }
827        DescriptorSource::Buf => {
828            println!("cargo:rerun-if-changed=buf.yaml");
829            if Path::new("buf.lock").exists() {
830                println!("cargo:rerun-if-changed=buf.lock");
831            }
832        }
833        DescriptorSource::Precompiled(p) => {
834            println!("cargo:rerun-if-changed={}", p.display());
835        }
836    }
837}
838
839#[cfg(test)]
840mod tests {
841    use buffa_descriptor::generated::descriptor::FileDescriptorProto;
842
843    use super::*;
844
845    #[test]
846    fn test_collect_messages_walks_nested_and_map_entries() {
847        let labels_entry = DescriptorProto {
848            name: Some("LabelsEntry".to_string()),
849            ..Default::default()
850        };
851        let nested_msg = DescriptorProto {
852            name: Some("Profile".to_string()),
853            ..Default::default()
854        };
855        let user = DescriptorProto {
856            name: Some("User".to_string()),
857            nested_type: vec![nested_msg, labels_entry],
858            ..Default::default()
859        };
860        let file = FileDescriptorProto {
861            name: Some("acme/v1/user.proto".to_string()),
862            package: Some("acme.v1".to_string()),
863            message_type: vec![user],
864            ..Default::default()
865        };
866        let fds = FileDescriptorSet {
867            file: vec![file],
868            ..Default::default()
869        };
870
871        let names = collect_message_full_names(&fds);
872        assert_eq!(
873            names,
874            vec![
875                "acme.v1.User".to_string(),
876                "acme.v1.User.Profile".to_string(),
877                "acme.v1.User.LabelsEntry".to_string(),
878            ]
879        );
880    }
881
882    #[test]
883    fn test_proto_relative_name_strips_longest_include() {
884        let got = proto_relative_name(
885            Path::new("proto/vendor/ext.proto"),
886            &[PathBuf::from("proto/"), PathBuf::from("proto/vendor/")],
887        );
888        assert_eq!(got, "ext.proto");
889    }
890
891    #[test]
892    fn test_compile_errors_when_binding_missing() {
893        let err = Builder::new()
894            .files(&["foo.proto"])
895            .out_dir(std::env::temp_dir())
896            .compile()
897            .unwrap_err();
898        assert!(matches!(err, Error::MissingDescriptorBinding));
899    }
900
901    #[test]
902    fn test_compile_errors_when_both_bindings_set() {
903        let err = Builder::new()
904            .descriptor_pool("crate::POOL")
905            .file_descriptor_set_bytes("crate::BYTES")
906            .out_dir(std::env::temp_dir())
907            .compile()
908            .unwrap_err();
909        assert!(matches!(err, Error::ConflictingDescriptorBindings));
910    }
911}