prost-protovalidate-build 0.6.0

Build-time code generator for zero-cost Protocol Buffer validation using buf.validate rules
Documentation
//! Compile-time code generator for Protocol Buffer validation.
//!
//! Generates `impl prost_protovalidate::Validate` for messages that have
//! **only** standard `buf.validate` rules (no CEL expressions). Validators
//! run through monomorphized direct field access at runtime — no
//! `prost-reflect` transcoding, no CEL interpreter on the hot path.
//! Messages with any CEL rules are, per builder configuration, skipped with
//! a `cargo:warning` (validate them with the runtime
//! `prost_protovalidate::Validator`), turned into a hard build error
//! ([`Builder::fail_on_runtime_only`] — a rule can never be silently
//! skipped), or, on the buffa backend, routed through an embedded runtime
//! engine ([`Builder::runtime_bridge`]) so the whole schema — CEL included —
//! validates through one uniform `msg.validate()`.
//!
//! # Backends
//!
//! Generated code targets [prost](https://crates.io/crates/prost) message
//! types by default. Select [`Backend::Buffa`] via [`Builder::backend`]
//! when the types were generated by
//! [buffa](https://crates.io/crates/buffa) (`buffa-build`, or a wrapper
//! such as `connectrpc-build`): presence checks go through
//! `MessageField`, enum accesses through `EnumValue::to_i32`, and type
//! paths keep verbatim proto names. Consumers pairing the buffa backend
//! with `prost-protovalidate`'s `default-features = false` get build-time
//! validation with no reflection or CEL in the runtime dependency graph.
//!
//! # Usage
//!
//! In your `build.rs`:
//!
//! ```rust,no_run
//! fn main() -> Result<(), Box<dyn std::error::Error>> {
//!     // First, compile protos with prost-build (writes descriptor set)
//!     let descriptor_path = std::path::PathBuf::from(std::env::var("OUT_DIR")?)
//!         .join("file_descriptor_set.bin");
//!     prost_build::Config::new()
//!         .file_descriptor_set_path(&descriptor_path)
//!         .compile_protos(&["proto/service.proto"], &["proto/"])?;
//!
//!     // Then generate validation impls
//!     prost_protovalidate_build::Builder::new()
//!         .file_descriptor_set_path(&descriptor_path)?
//!         .compile()?;
//!     Ok(())
//! }
//! ```
//!
//! Then include the generated code alongside the prost-generated code:
//!
//! ```rust,ignore
//! include!(concat!(env!("OUT_DIR"), "/validate_impl.rs"));
//! ```

mod backend;
mod codegen;
mod message;
mod naming;
mod rules;

use std::fs;
use std::path::{Path, PathBuf};

use prost_reflect::DescriptorPool;

pub use backend::Backend;

/// Builder for configuring and running the validation code generator.
#[derive(Default)]
pub struct Builder {
    file_descriptor_set_bytes: Option<Vec<u8>>,
    out_dir: Option<PathBuf>,
    extern_paths: Vec<(String, String)>,
    backend: Backend,
    fail_on_runtime_only: bool,
    runtime_bridge: bool,
}

impl Builder {
    /// Create a new builder with default settings.
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Set the file descriptor set bytes directly.
    #[must_use]
    pub fn file_descriptor_set_bytes(mut self, bytes: Vec<u8>) -> Self {
        self.file_descriptor_set_bytes = Some(bytes);
        self
    }

    /// Read the file descriptor set from a file path.
    ///
    /// # Errors
    ///
    /// Returns an error if the file cannot be read.
    pub fn file_descriptor_set_path(mut self, path: impl AsRef<Path>) -> Result<Self, Error> {
        let bytes = fs::read(path.as_ref()).map_err(|e| Error::Io {
            path: path.as_ref().to_path_buf(),
            source: e,
        })?;
        self.file_descriptor_set_bytes = Some(bytes);
        Ok(self)
    }

    /// Override the output directory (defaults to `OUT_DIR` env var).
    #[must_use]
    pub fn out_dir(mut self, path: impl Into<PathBuf>) -> Self {
        self.out_dir = Some(path.into());
        self
    }

    /// Map a proto package path to a Rust module path.
    ///
    /// This is equivalent to prost-build's `extern_path` and should match
    /// your prost-build configuration.
    ///
    /// # Example
    ///
    /// ```rust,no_run
    /// prost_protovalidate_build::Builder::new()
    ///     .extern_path(".my.package", "::my_crate::my_package");
    /// ```
    #[must_use]
    pub fn extern_path(
        mut self,
        proto_path: impl Into<String>,
        rust_path: impl Into<String>,
    ) -> Self {
        self.extern_paths
            .push((proto_path.into(), rust_path.into()));
        self
    }

    /// Select the protobuf runtime the generated impls target (defaults to
    /// [`Backend::Prost`]).
    ///
    /// Use [`Backend::Buffa`] when the message types were generated by
    /// `buffa-build` (or a wrapper such as `connectrpc-build`): presence
    /// checks go through `MessageField`, enum accesses through
    /// `EnumValue::to_i32`, and type paths keep verbatim proto names
    /// (`UUID` stays `UUID` — prost would rename it to `Uuid`).
    #[must_use]
    pub fn backend(mut self, backend: Backend) -> Self {
        self.backend = backend;
        self
    }

    /// Turn "route to runtime validator" outcomes into hard build errors.
    ///
    /// By default, messages the generator cannot cover (CEL rules,
    /// unsupported shapes) are skipped with a `cargo:warning`, on the
    /// assumption that the runtime `Validator` picks them up. Consumers that
    /// ship **no** runtime validation path (e.g. build-time-only validation
    /// against a slim `prost-protovalidate`) must set this to `true` so a
    /// rule can never be silently skipped.
    #[must_use]
    pub fn fail_on_runtime_only(mut self, fail: bool) -> Self {
        self.fail_on_runtime_only = fail;
        self
    }

    /// Route messages the generator cannot cover to the runtime `Validator`
    /// instead of skipping (`cargo:warning`) or hard-failing them.
    ///
    /// In this mode a message with CEL rules (or a shape the generator does not
    /// support) receives an `impl Validate` that encodes the value to protobuf
    /// wire bytes and validates it through an embedded descriptor set — reusing
    /// the full runtime engine, including the CEL interpreter, rather than a
    /// second code path. This gives buffa-generated types full rule coverage
    /// (matching the runtime `Validator`) at the cost of pulling
    /// `prost-protovalidate`'s `reflect`/`cel` features into the consumer for
    /// the routed messages.
    ///
    /// # Feature tiers
    ///
    /// The default build-time path (standard rules, especially with
    /// [`fail_on_runtime_only`](Builder::fail_on_runtime_only)) stays
    /// reflection- and CEL-free. `runtime_bridge` is the explicit opt-in that
    /// crosses into the runtime engine: the routed messages require
    /// `prost-protovalidate` built with `reflect` (and `cel` for CEL rules),
    /// otherwise the generated code fails to compile.
    ///
    /// # `OUT_DIR` requirement
    ///
    /// The generated bridge embeds the descriptor set next to the generated code
    /// and loads it via `include_bytes!(concat!(env!("OUT_DIR"), …))`, so the
    /// generated file must be included from `OUT_DIR` (the default). Do **not**
    /// combine `runtime_bridge` with an [`out_dir`](Builder::out_dir) override
    /// for code that is subsequently compiled — the embedded descriptor set
    /// would not be found.
    ///
    /// Only supported with [`Backend::Buffa`], and mutually exclusive with
    /// [`Builder::fail_on_runtime_only`]; [`Builder::compile`] returns
    /// [`Error::ConflictingOptions`] otherwise.
    #[must_use]
    pub fn runtime_bridge(mut self, enable: bool) -> Self {
        self.runtime_bridge = enable;
        self
    }

    /// Run the code generator.
    ///
    /// # Errors
    ///
    /// Returns an error if the descriptor set is missing, cannot be parsed,
    /// or the output file cannot be written.
    pub fn compile(self) -> Result<(), Error> {
        if self.runtime_bridge && self.fail_on_runtime_only {
            return Err(Error::ConflictingOptions(
                "runtime_bridge and fail_on_runtime_only are mutually exclusive".to_string(),
            ));
        }
        if self.runtime_bridge && self.backend != Backend::Buffa {
            return Err(Error::ConflictingOptions(
                "runtime_bridge is only supported with Backend::Buffa".to_string(),
            ));
        }

        let fds_bytes = self
            .file_descriptor_set_bytes
            .ok_or(Error::MissingDescriptorSet)?;

        // Decode the raw bytes directly into a prost-reflect DescriptorPool.
        // Using prost_types::FileDescriptorSet::decode() first would strip
        // extension data (buf.validate.field, etc.) from proto options since
        // prost does not preserve unknown fields by default.
        let pool = DescriptorPool::decode(fds_bytes.as_slice())
            .map_err(|e| Error::Decode(e.to_string()))?;

        let out_dir = match self.out_dir {
            Some(dir) => dir,
            None => PathBuf::from(std::env::var("OUT_DIR").map_err(|_| Error::MissingOutDir)?),
        };

        let naming_ctx = naming::NamingContext::new(&self.extern_paths, self.backend, &pool);
        let generated = codegen::generate(
            &pool,
            &naming_ctx,
            self.fail_on_runtime_only,
            self.runtime_bridge,
        )?;

        let file = syn::parse2(generated.tokens).map_err(|e| Error::Codegen(e.to_string()))?;
        let formatted = prettyplease::unparse(&file);

        fs::create_dir_all(&out_dir).map_err(|e| Error::Io {
            path: out_dir.clone(),
            source: e,
        })?;

        let output_path = out_dir.join("validate_impl.rs");
        fs::write(&output_path, formatted).map_err(|e| Error::Io {
            path: output_path,
            source: e,
        })?;

        // When any message was routed through the runtime bridge, embed the
        // (raw) descriptor set next to the generated code so the emitted
        // `RuntimeBridge::from_fds(include_bytes!(...))` resolves at the
        // consumer's compile time.
        if generated.needs_fds {
            let fds_path = out_dir.join("prost_protovalidate_validate.fds");
            fs::write(&fds_path, &fds_bytes).map_err(|e| Error::Io {
                path: fds_path,
                source: e,
            })?;
        }

        Ok(())
    }
}

/// Errors that can occur during code generation.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
    /// No file descriptor set was provided.
    #[error(
        "no file descriptor set provided; call file_descriptor_set_bytes() or file_descriptor_set_path()"
    )]
    MissingDescriptorSet,

    /// The `OUT_DIR` environment variable is not set.
    #[error("OUT_DIR environment variable not set; call out_dir() or run from build.rs")]
    MissingOutDir,

    /// Failed to decode the file descriptor set.
    #[error("failed to decode file descriptor set: {0}")]
    Decode(String),

    /// Code generation produced invalid tokens.
    #[error("code generation error: {0}")]
    Codegen(String),

    /// I/O error reading or writing files.
    #[error("I/O error at {path}: {source}")]
    Io {
        /// The path that caused the error.
        path: PathBuf,
        /// The underlying I/O error.
        source: std::io::Error,
    },

    /// A constraint could not be decoded from proto extensions.
    #[error("constraint decode error: {0}")]
    ConstraintDecode(String),

    /// A message needs the runtime `Validator` (CEL rules or an unsupported
    /// shape) but [`Builder::fail_on_runtime_only`] was set.
    #[error(
        "message requires runtime validation but fail_on_runtime_only is set: {0}; \
         either drop the offending rule or validate this message with the runtime Validator"
    )]
    RuntimeOnly(String),

    /// Two mutually incompatible builder options were combined:
    /// [`Builder::runtime_bridge`] with [`Builder::fail_on_runtime_only`], or
    /// `runtime_bridge` on a non-buffa backend.
    #[error("conflicting builder options: {0}")]
    ConflictingOptions(String),
}