Skip to main content

connectrpc_axum_build/
lib.rs

1use crate::merge::append_generated_file;
2use r#gen::AxumConnectServiceGenerator;
3use schema::SchemaSet;
4use std::io::Result;
5use std::marker::PhantomData;
6use std::path::{Path, PathBuf};
7
8const WELL_KNOWN_TYPES_PROTO_PATH: &str = "google.protobuf";
9const DEFAULT_WELL_KNOWN_TYPES_RUST_PATH: &str = "::pbjson_types";
10
11/// Code generation module for service builders.
12mod r#gen;
13mod include_file;
14mod merge;
15mod schema;
16
17/// Source of proto files - either auto-discovered from a directory or explicit file list.
18enum ProtoSource {
19    /// Auto-discover all .proto files in this directory recursively.
20    Directory(PathBuf),
21    /// Explicit list of proto files and include directories.
22    Files {
23        protos: Vec<PathBuf>,
24        includes: Vec<PathBuf>,
25    },
26}
27
28// ============================================================================
29// Type-state marker types
30// ============================================================================
31
32/// Marker indicating a feature is enabled.
33pub struct Enabled;
34
35/// Marker indicating a feature is disabled.
36pub struct Disabled;
37
38/// Marker indicating no proto source has been set yet.
39pub struct NoSource;
40
41/// Marker indicating a proto source has been set.
42pub struct WithSource(ProtoSource);
43
44/// Trait to convert type markers to runtime booleans.
45pub trait BuildMarker {
46    /// The boolean value this marker represents.
47    const VALUE: bool;
48}
49
50impl BuildMarker for Enabled {
51    const VALUE: bool = true;
52}
53
54impl BuildMarker for Disabled {
55    const VALUE: bool = false;
56}
57
58/// Builder for compiling proto files with optional configuration.
59///
60/// Type parameters control code generation:
61/// - `Source`: Whether a proto source has been provided (`NoSource` or `WithSource`)
62/// - `Connect`: Whether to generate Connect service handlers
63/// - `Tonic`: Whether to generate Tonic gRPC server stubs (requires `tonic` feature)
64/// - `TonicClient`: Whether to generate Tonic gRPC client stubs (requires `tonic-client` feature)
65/// - `ConnectClient`: Whether to generate typed Connect RPC client code
66///
67/// Use [`builder()`] to create a builder without a source, then call
68/// [`compile_dir()`](CompileBuilder::compile_dir) or
69/// [`compile_protos()`](CompileBuilder::compile_protos) to set the source.
70/// Alternatively, use the free functions [`compile_dir()`](crate::compile_dir) or
71/// [`compile_protos()`](crate::compile_protos) as shortcuts.
72///
73/// To compile multiple proto roots, use separate builders:
74///
75/// ```rust,ignore
76/// connectrpc_axum_build::compile_dir("proto1")
77///     .include_file("protos1.rs")
78///     .compile()?;
79/// connectrpc_axum_build::compile_dir("proto2")
80///     .include_file("protos2.rs")
81///     .compile()?;
82/// ```
83pub struct CompileBuilder<
84    Source = NoSource,
85    Connect = Enabled,
86    Tonic = Disabled,
87    TonicClient = Disabled,
88    ConnectClient = Disabled,
89> {
90    source: Source,
91    out_dir: Option<PathBuf>,
92    include_file: Option<PathBuf>,
93    extern_reexports: Vec<(String, String)>,
94    #[cfg(feature = "fetch-protoc")]
95    protoc_path: Option<PathBuf>,
96    prost_config: Option<Box<dyn Fn(&mut prost_build::Config)>>,
97    pbjson_config: Option<Box<dyn Fn(&mut pbjson_build::Builder)>>,
98    #[cfg(feature = "tonic")]
99    tonic_config: Option<Box<dyn Fn(tonic_prost_build::Builder) -> tonic_prost_build::Builder>>,
100    #[cfg(feature = "tonic-client")]
101    tonic_client_config:
102        Option<Box<dyn Fn(tonic_prost_build::Builder) -> tonic_prost_build::Builder>>,
103    _marker: PhantomData<(Connect, Tonic, TonicClient, ConnectClient)>,
104}
105
106// ============================================================================
107// Methods to set proto source (only available when Source = NoSource)
108// ============================================================================
109
110impl<C, T, TC, CC> CompileBuilder<NoSource, C, T, TC, CC> {
111    /// Set a directory of proto files to compile.
112    ///
113    /// Auto-discovers all `.proto` files in the directory recursively.
114    ///
115    /// # Example
116    ///
117    /// ```rust,ignore
118    /// fn main() -> Result<(), Box<dyn std::error::Error>> {
119    ///     connectrpc_axum_build::builder()
120    ///         .compile_dir("proto")
121    ///         .compile()?;
122    ///     Ok(())
123    /// }
124    /// ```
125    pub fn compile_dir(self, dir: impl AsRef<Path>) -> CompileBuilder<WithSource, C, T, TC, CC> {
126        CompileBuilder {
127            source: WithSource(ProtoSource::Directory(dir.as_ref().to_path_buf())),
128            out_dir: self.out_dir,
129            include_file: self.include_file,
130            extern_reexports: self.extern_reexports,
131
132            #[cfg(feature = "fetch-protoc")]
133            protoc_path: self.protoc_path,
134            prost_config: self.prost_config,
135            pbjson_config: self.pbjson_config,
136            #[cfg(feature = "tonic")]
137            tonic_config: self.tonic_config,
138            #[cfg(feature = "tonic-client")]
139            tonic_client_config: self.tonic_client_config,
140            _marker: PhantomData,
141        }
142    }
143
144    /// Set specific proto files to compile.
145    ///
146    /// # Arguments
147    ///
148    /// * `protos` - Proto files to compile
149    /// * `includes` - Directories to search for imports
150    ///
151    /// # Example
152    ///
153    /// ```rust,ignore
154    /// fn main() -> Result<(), Box<dyn std::error::Error>> {
155    ///     connectrpc_axum_build::builder()
156    ///         .compile_protos(&["proto/service.proto"], &["proto"])
157    ///         .compile()?;
158    ///     Ok(())
159    /// }
160    /// ```
161    pub fn compile_protos<P: AsRef<Path>>(
162        self,
163        protos: &[P],
164        includes: &[P],
165    ) -> CompileBuilder<WithSource, C, T, TC, CC> {
166        CompileBuilder {
167            source: WithSource(ProtoSource::Files {
168                protos: protos.iter().map(|p| p.as_ref().to_path_buf()).collect(),
169                includes: includes.iter().map(|p| p.as_ref().to_path_buf()).collect(),
170            }),
171            out_dir: self.out_dir,
172            include_file: self.include_file,
173            extern_reexports: self.extern_reexports,
174
175            #[cfg(feature = "fetch-protoc")]
176            protoc_path: self.protoc_path,
177            prost_config: self.prost_config,
178            pbjson_config: self.pbjson_config,
179            #[cfg(feature = "tonic")]
180            tonic_config: self.tonic_config,
181            #[cfg(feature = "tonic-client")]
182            tonic_client_config: self.tonic_client_config,
183            _marker: PhantomData,
184        }
185    }
186}
187
188// ============================================================================
189// Methods available when Connect = Enabled
190// ============================================================================
191
192impl<S, T, TC, CC> CompileBuilder<S, Enabled, T, TC, CC> {
193    /// Skip generating Connect server code.
194    ///
195    /// When called, only message types and serde implementations are generated.
196    /// No Connect service builders (e.g., `HelloWorldServiceBuilder`) will be created.
197    /// Generators that were already enabled (tonic, tonic client, Connect client)
198    /// remain enabled.
199    ///
200    /// **Note:** After calling this, `with_tonic()` is no longer available since
201    /// tonic server stubs depend on the Connect service module. Call it before
202    /// `no_connect_server()` if you need tonic server stubs.
203    ///
204    /// Use this when you only need:
205    /// - Protobuf message types with JSON serialization
206    /// - Connect client (via `with_connect_client()`)
207    /// - Tonic client (via `with_tonic_client()`)
208    ///
209    /// # Example
210    ///
211    /// ```rust,ignore
212    /// fn main() -> Result<(), Box<dyn std::error::Error>> {
213    ///     connectrpc_axum_build::compile_dir("proto")
214    ///         .no_connect_server()  // Skip server code
215    ///         .with_connect_client() // Generate client code
216    ///         .compile()?;
217    ///     Ok(())
218    /// }
219    /// ```
220    pub fn no_connect_server(self) -> CompileBuilder<S, Disabled, T, TC, CC> {
221        CompileBuilder {
222            source: self.source,
223            out_dir: self.out_dir,
224            include_file: self.include_file,
225            extern_reexports: self.extern_reexports,
226
227            #[cfg(feature = "fetch-protoc")]
228            protoc_path: self.protoc_path,
229            prost_config: self.prost_config,
230            pbjson_config: self.pbjson_config,
231            #[cfg(feature = "tonic")]
232            tonic_config: self.tonic_config,
233            #[cfg(feature = "tonic-client")]
234            tonic_client_config: self.tonic_client_config,
235            _marker: PhantomData,
236        }
237    }
238}
239
240// ============================================================================
241// Methods available when Connect = Enabled AND Tonic = Disabled (enable tonic)
242// ============================================================================
243
244#[cfg(feature = "tonic")]
245impl<S, TC, CC> CompileBuilder<S, Enabled, Disabled, TC, CC> {
246    /// Enable generating tonic gRPC server stubs (second pass) + tonic-compatible helpers in first pass.
247    ///
248    /// **Note:** After calling this, `no_connect_server()` is no longer available since
249    /// tonic server stubs depend on the Connect service module.
250    pub fn with_tonic(self) -> CompileBuilder<S, Enabled, Enabled, TC, CC> {
251        CompileBuilder {
252            source: self.source,
253            out_dir: self.out_dir,
254            include_file: self.include_file,
255            extern_reexports: self.extern_reexports,
256
257            #[cfg(feature = "fetch-protoc")]
258            protoc_path: self.protoc_path,
259            prost_config: self.prost_config,
260            pbjson_config: self.pbjson_config,
261            tonic_config: self.tonic_config,
262            #[cfg(feature = "tonic-client")]
263            tonic_client_config: self.tonic_client_config,
264            _marker: PhantomData,
265        }
266    }
267}
268
269// ============================================================================
270// Methods available when Tonic = Enabled (configure tonic)
271// ============================================================================
272
273#[cfg(feature = "tonic")]
274impl<S, C, TC, CC> CompileBuilder<S, C, Enabled, TC, CC> {
275    /// Customize the tonic prost builder with a configuration closure.
276    ///
277    /// The closure is applied before the required internal configuration. Internal settings
278    /// (like `build_client(false)`, `build_server(true)`, `compile_well_known_types(false)`,
279    /// `out_dir`, and `extern_path` mappings) will be applied after and take precedence.
280    ///
281    /// **Important:** This only affects service trait generation, not message types.
282    /// Message types are generated in Pass 1 and reused via `extern_path` in Pass 2.
283    /// To customize message types, use [`with_prost_config`](Self::with_prost_config) instead.
284    ///
285    /// # Example
286    ///
287    /// ```rust,ignore
288    /// fn main() -> Result<(), Box<dyn std::error::Error>> {
289    ///     connectrpc_axum_build::compile_dir("proto")
290    ///         .with_prost_config(|config| {
291    ///             // Configure message types here (Pass 1)
292    ///             config.type_attribute("MyMessage", "#[derive(Hash)]");
293    ///         })
294    ///         .with_tonic()
295    ///         .with_tonic_prost_config(|builder| {
296    ///             // Configure service generation here (Pass 2)
297    ///             // Note: type_attribute for messages won't work here
298    ///             builder
299    ///         })
300    ///         .compile()?;
301    ///     Ok(())
302    /// }
303    /// ```
304    pub fn with_tonic_prost_config<F>(mut self, f: F) -> Self
305    where
306        F: Fn(tonic_prost_build::Builder) -> tonic_prost_build::Builder + 'static,
307    {
308        self.tonic_config = Some(Box::new(f));
309        self
310    }
311}
312
313// ============================================================================
314// Methods available on all builder states (configuration)
315// ============================================================================
316
317impl<S, C, T, TC, CC> CompileBuilder<S, C, T, TC, CC> {
318    /// Fetch and configure the protoc compiler.
319    ///
320    /// Downloads the specified version of protoc and sets the `PROTOC` environment
321    /// variable so that prost-build uses the downloaded binary.
322    ///
323    /// # Arguments
324    ///
325    /// * `version` - The protoc version to download. Defaults to "31.1" if `None`.
326    /// * `path` - The directory to download protoc into. Defaults to `OUT_DIR` if `None`.
327    ///
328    /// # Example
329    ///
330    /// ```rust,ignore
331    /// fn main() -> Result<(), Box<dyn std::error::Error>> {
332    ///     connectrpc_axum_build::compile_dir("proto")
333    ///         .fetch_protoc(None, None)?  // Use defaults
334    ///         .compile()?;
335    ///     Ok(())
336    /// }
337    /// ```
338    #[cfg(feature = "fetch-protoc")]
339    pub fn fetch_protoc(mut self, version: Option<&str>, path: Option<&Path>) -> Result<Self> {
340        let version = version.unwrap_or("31.1");
341        let out_dir = match path {
342            Some(p) => p.to_path_buf(),
343            None => {
344                let dir = std::env::var("OUT_DIR")
345                    .map_err(|e| std::io::Error::other(format!("OUT_DIR not set: {e}")))?;
346                PathBuf::from(dir)
347            }
348        };
349
350        let protoc_path = protoc_fetcher::protoc(version, &out_dir)
351            .map_err(|e| std::io::Error::other(format!("failed to fetch protoc: {e}")))?;
352
353        self.protoc_path = Some(protoc_path);
354        Ok(self)
355    }
356
357    /// Customize the prost builder with a configuration closure.
358    ///
359    /// The closure receives a mutable reference to `prost_build::Config` and is applied
360    /// before the required internal configuration. Internal settings (like file descriptor
361    /// set path) will be applied after and take precedence.
362    ///
363    /// Use this to add type attributes or other prost configuration.
364    ///
365    /// If you map protobuf packages to external crates with `extern_path`, and
366    /// also generate pbjson serde code, configure matching pbjson extern mappings
367    /// via [`with_pbjson_config`](Self::with_pbjson_config).
368    ///
369    /// # Example
370    ///
371    /// ```rust,ignore
372    /// fn main() -> Result<(), Box<dyn std::error::Error>> {
373    ///     connectrpc_axum_build::compile_dir("proto")
374    ///         .with_prost_config(|config| {
375    ///             config.type_attribute(".", "#[derive(Hash)]");
376    ///         })
377    ///         .compile()?;
378    ///     Ok(())
379    /// }
380    /// ```
381    pub fn with_prost_config<F>(mut self, f: F) -> Self
382    where
383        F: Fn(&mut prost_build::Config) + 'static,
384    {
385        self.prost_config = Some(Box::new(f));
386        self
387    }
388
389    /// Customize the pbjson builder with a configuration closure.
390    ///
391    /// The closure receives a mutable reference to `pbjson_build::Builder` and is
392    /// applied before descriptor registration and code generation.
393    ///
394    /// Use this when pbjson needs explicit extern mappings that mirror prost's
395    /// `extern_path` behavior. Well-known types are already mapped to
396    /// `::pbjson_types` by default.
397    pub fn with_pbjson_config<F>(mut self, f: F) -> Self
398    where
399        F: Fn(&mut pbjson_build::Builder) + 'static,
400    {
401        self.pbjson_config = Some(Box::new(f));
402        self
403    }
404
405    /// Set the output directory for generated code.
406    ///
407    /// By default, generated code is written to `OUT_DIR` (set by Cargo during build).
408    /// Use this method to specify a custom output directory instead.
409    ///
410    /// # Example
411    ///
412    /// ```rust,ignore
413    /// fn main() -> Result<(), Box<dyn std::error::Error>> {
414    ///     connectrpc_axum_build::compile_dir("proto")
415    ///         .out_dir("src/generated")
416    ///         .compile()?;
417    ///     Ok(())
418    /// }
419    /// ```
420    pub fn out_dir(mut self, path: impl AsRef<Path>) -> Self {
421        self.out_dir = Some(path.as_ref().to_path_buf());
422        self
423    }
424
425    /// Generate a single include file that provides a nested `pub mod` tree
426    /// for all compiled protobuf packages.
427    ///
428    /// Instead of manually writing `mod pb { include!(...) }` boilerplate for each
429    /// package, this generates a single file that you can include with:
430    ///
431    /// ```rust,ignore
432    /// include!(concat!(env!("OUT_DIR"), "/protos.rs"));
433    /// ```
434    ///
435    /// For dotted package names like `buf.validate`, this generates properly nested
436    /// modules:
437    ///
438    /// ```rust,ignore
439    /// pub mod buf {
440    ///     pub mod validate {
441    ///         include!(concat!(env!("OUT_DIR"), "/buf.validate.rs"));
442    ///     }
443    /// }
444    /// ```
445    ///
446    /// # Example
447    ///
448    /// ```rust,ignore
449    /// fn main() -> Result<(), Box<dyn std::error::Error>> {
450    ///     connectrpc_axum_build::compile_dir("proto")
451    ///         .include_file("protos.rs")
452    ///         .compile()?;
453    ///     Ok(())
454    /// }
455    /// ```
456    pub fn include_file(mut self, path: impl AsRef<Path>) -> Self {
457        self.include_file = Some(path.as_ref().to_path_buf());
458        self
459    }
460
461    /// Register an extern module re-export for the include file.
462    ///
463    /// When using `extern_path` in prost config to map a protobuf package to an
464    /// external crate (e.g. `config.extern_path(".acme.types", "::acme_types")`),
465    /// prost-generated code may still reference types through relative module paths
466    /// (e.g. `super::super::google::protobuf::...`). This method creates a shim module
467    /// in the include file that re-exports the external crate's types, allowing those
468    /// relative paths to resolve.
469    ///
470    /// # Arguments
471    ///
472    /// * `proto_path` - The dotted protobuf package name (e.g. `"acme.types"`)
473    /// * `rust_path` - The Rust crate path to re-export (e.g. `"::acme_types"`)
474    ///
475    /// # Example
476    ///
477    /// ```rust,ignore
478    /// fn main() -> Result<(), Box<dyn std::error::Error>> {
479    ///     connectrpc_axum_build::compile_dir("proto")
480    ///         .with_prost_config(|config| {
481    ///             config.extern_path(".acme.types", "::acme_types");
482    ///         })
483    ///         .include_file("protos.rs")
484    ///         .extern_module("acme.types", "::acme_types")
485    ///         .compile()?;
486    ///     Ok(())
487    /// }
488    /// ```
489    pub fn extern_module(
490        mut self,
491        proto_path: impl Into<String>,
492        rust_path: impl Into<String>,
493    ) -> Self {
494        self.extern_reexports
495            .push((proto_path.into(), rust_path.into()));
496        self
497    }
498}
499
500// ============================================================================
501// Methods available when TonicClient = Disabled (enable tonic client)
502// ============================================================================
503
504#[cfg(feature = "tonic-client")]
505impl<S, C, T, CC> CompileBuilder<S, C, T, Disabled, CC> {
506    /// Enable generating tonic gRPC client stubs.
507    ///
508    /// Generates client code using `tonic-prost-build`. The client code is appended
509    /// to the same `{package}.rs` file alongside message types and other generated code.
510    ///
511    /// This can be used independently of `with_tonic()` (server stubs).
512    ///
513    /// # Example
514    ///
515    /// ```rust,ignore
516    /// fn main() -> Result<(), Box<dyn std::error::Error>> {
517    ///     connectrpc_axum_build::compile_dir("proto")
518    ///         .with_tonic_client()  // Generate gRPC clients
519    ///         .compile()?;
520    ///     Ok(())
521    /// }
522    /// ```
523    pub fn with_tonic_client(self) -> CompileBuilder<S, C, T, Enabled, CC> {
524        CompileBuilder {
525            source: self.source,
526            out_dir: self.out_dir,
527            include_file: self.include_file,
528            extern_reexports: self.extern_reexports,
529
530            #[cfg(feature = "fetch-protoc")]
531            protoc_path: self.protoc_path,
532            prost_config: self.prost_config,
533            pbjson_config: self.pbjson_config,
534            #[cfg(feature = "tonic")]
535            tonic_config: self.tonic_config,
536            tonic_client_config: self.tonic_client_config,
537            _marker: PhantomData,
538        }
539    }
540}
541
542// ============================================================================
543// Methods available when TonicClient = Enabled (configure tonic client)
544// ============================================================================
545
546#[cfg(feature = "tonic-client")]
547impl<S, C, T, CC> CompileBuilder<S, C, T, Enabled, CC> {
548    /// Customize the tonic prost builder for client generation.
549    ///
550    /// The closure is applied before internal configuration. Internal settings
551    /// (like `build_client(true)`, `build_server(false)`, `compile_well_known_types(false)`,
552    /// `out_dir`, and `extern_path` mappings) will be applied after and take precedence.
553    ///
554    /// # Example
555    ///
556    /// ```rust,ignore
557    /// fn main() -> Result<(), Box<dyn std::error::Error>> {
558    ///     connectrpc_axum_build::compile_dir("proto")
559    ///         .with_tonic_client()
560    ///         .with_tonic_client_config(|builder| {
561    ///             builder.build_transport(false)  // Disable transport feature
562    ///         })
563    ///         .compile()?;
564    ///     Ok(())
565    /// }
566    /// ```
567    pub fn with_tonic_client_config<F>(mut self, f: F) -> Self
568    where
569        F: Fn(tonic_prost_build::Builder) -> tonic_prost_build::Builder + 'static,
570    {
571        self.tonic_client_config = Some(Box::new(f));
572        self
573    }
574}
575
576// ============================================================================
577// Methods available when ConnectClient = Disabled (enable connect client)
578// ============================================================================
579
580impl<S, C, T, TC> CompileBuilder<S, C, T, TC, Disabled> {
581    /// Enable generating typed Connect RPC client code.
582    ///
583    /// Generates client structs with typed methods for each RPC procedure.
584    /// The generated client wraps [`ConnectClient`](connectrpc_axum_client::ConnectClient)
585    /// and provides a more ergonomic API.
586    ///
587    /// This can be used independently of server code. Use `no_connect_server()` first
588    /// if you only want client code without server code.
589    ///
590    /// # Example
591    ///
592    /// ```rust,ignore
593    /// fn main() -> Result<(), Box<dyn std::error::Error>> {
594    ///     connectrpc_axum_build::compile_dir("proto")
595    ///         .with_connect_client()  // Generate typed Connect clients
596    ///         .compile()?;
597    ///     Ok(())
598    /// }
599    /// ```
600    ///
601    /// # Generated Code
602    ///
603    /// For a service like:
604    /// ```protobuf
605    /// service HelloWorldService {
606    ///   rpc SayHello(HelloRequest) returns (HelloResponse);
607    /// }
608    /// ```
609    ///
610    /// This generates:
611    /// - `HELLO_WORLD_SERVICE_SERVICE_NAME` constant
612    /// - `hello_world_service_procedures` module with procedure path constants
613    /// - `HelloWorldServiceClient` struct with typed `say_hello()` method
614    /// - `HelloWorldServiceClientBuilder` for configuration
615    pub fn with_connect_client(self) -> CompileBuilder<S, C, T, TC, Enabled> {
616        CompileBuilder {
617            source: self.source,
618            out_dir: self.out_dir,
619            include_file: self.include_file,
620            extern_reexports: self.extern_reexports,
621
622            #[cfg(feature = "fetch-protoc")]
623            protoc_path: self.protoc_path,
624            prost_config: self.prost_config,
625            pbjson_config: self.pbjson_config,
626            #[cfg(feature = "tonic")]
627            tonic_config: self.tonic_config,
628            #[cfg(feature = "tonic-client")]
629            tonic_client_config: self.tonic_client_config,
630            _marker: PhantomData,
631        }
632    }
633}
634
635// ============================================================================
636// Compile method - only available when Source = WithSource
637// ============================================================================
638
639impl<C: BuildMarker, T: BuildMarker, TC: BuildMarker, CC: BuildMarker>
640    CompileBuilder<WithSource, C, T, TC, CC>
641{
642    /// Execute code generation for the proto source.
643    ///
644    /// When `include_file` is set, a module tree file is generated after
645    /// compilation from the packages compiled in this run.
646    pub fn compile(&self) -> Result<()> {
647        let schema = self.compile_source(&self.source.0)?;
648
649        if let Some(ref include_path) = self.include_file {
650            let out_dir = match &self.out_dir {
651                Some(dir) => dir.display().to_string(),
652                None => std::env::var("OUT_DIR")
653                    .map_err(|e| std::io::Error::other(format!("OUT_DIR not set: {e}")))?,
654            };
655            let file_name = include_path
656                .file_name()
657                .and_then(|n| n.to_str())
658                .ok_or_else(|| {
659                    std::io::Error::new(
660                        std::io::ErrorKind::InvalidInput,
661                        format!("Invalid include file path: {}", include_path.display()),
662                    )
663                })?;
664            let file_stems: std::collections::BTreeSet<String> = schema
665                .packages
666                .iter()
667                .map(|package| schema.prost().rust_package_file_stem(package))
668                .collect();
669            include_file::generate(
670                file_name,
671                &out_dir,
672                &self.extern_reexports,
673                self.out_dir.is_none(),
674                &file_stems,
675            )?;
676        }
677
678        Ok(())
679    }
680
681    fn compile_source(&self, source: &ProtoSource) -> Result<SchemaSet> {
682        use std::fs;
683
684        let generate_handlers = C::VALUE;
685        let grpc = T::VALUE;
686        #[cfg(feature = "tonic-client")]
687        let grpc_client = TC::VALUE;
688        let connect_client = CC::VALUE;
689        let out_dir = match &self.out_dir {
690            Some(dir) => dir.display().to_string(),
691            None => std::env::var("OUT_DIR")
692                .map_err(|e| std::io::Error::other(format!("OUT_DIR not set: {e}")))?,
693        };
694        let descriptor_path = format!("{}/descriptor.bin", out_dir);
695
696        // Resolve proto files and includes from the source
697        let (proto_files, includes) = Self::resolve_source(source)?;
698
699        // -------- Pass 1: prost + connect (conditionally) --------
700        let mut config = prost_build::Config::default();
701
702        // Set custom output directory if specified
703        if self.out_dir.is_some() {
704            config.out_dir(&out_dir);
705        }
706
707        // Apply user's prost configuration
708        if let Some(ref config_fn) = self.prost_config {
709            config_fn(&mut config);
710        }
711
712        let well_known_types_rust_path = self
713            .extern_reexports
714            .iter()
715            .find(|(proto_path, _)| proto_path == WELL_KNOWN_TYPES_PROTO_PATH)
716            .map(|(_, rust_path)| rust_path.as_str())
717            .unwrap_or(DEFAULT_WELL_KNOWN_TYPES_RUST_PATH);
718        config.compile_well_known_types();
719        config.extern_path(
720            format!(".{WELL_KNOWN_TYPES_PROTO_PATH}"),
721            well_known_types_rust_path,
722        );
723
724        // Set protoc executable if fetched (internal config takes precedence).
725        // tonic-prost-build has no per-builder protoc setter, so also export
726        // PROTOC for the tonic passes, which resolve protoc from the
727        // environment.
728        #[cfg(feature = "fetch-protoc")]
729        if let Some(ref protoc) = self.protoc_path {
730            config.protoc_executable(protoc);
731            // SAFETY: build scripts run single-threaded at this point; no other
732            // thread is reading or writing the environment concurrently.
733            unsafe {
734                std::env::set_var("PROTOC", protoc);
735            }
736        }
737
738        // Always generate descriptor set for pbjson-build (internal config takes precedence)
739        config.file_descriptor_set_path(&descriptor_path);
740
741        let include_refs: Vec<&Path> = includes.iter().map(|p| p.as_path()).collect();
742        config.compile_protos(&proto_files, &include_refs)?;
743
744        let descriptor_bytes = fs::read(&descriptor_path)
745            .map_err(|e| std::io::Error::other(format!("read descriptor: {e}")))?;
746
747        let mut extern_reexports = self.extern_reexports.clone();
748        if !extern_reexports
749            .iter()
750            .any(|(proto_path, _)| proto_path == WELL_KNOWN_TYPES_PROTO_PATH)
751        {
752            extern_reexports.push((
753                WELL_KNOWN_TYPES_PROTO_PATH.to_string(),
754                DEFAULT_WELL_KNOWN_TYPES_RUST_PATH.to_string(),
755            ));
756        }
757        let schema = SchemaSet::from_descriptor_bytes(&descriptor_bytes)?
758            .with_extern_overrides(&extern_reexports);
759
760        let connect_generator = AxumConnectServiceGenerator::new()
761            .with_connect_server(generate_handlers)
762            .with_tonic(grpc)
763            .with_connect_client(connect_client);
764
765        connect_generator.append_to_out_dir(&schema, &out_dir)?;
766
767        // -------- Pass 1.5: pbjson serde implementations (always) --------
768        Self::generate_pbjson(
769            &out_dir,
770            &descriptor_bytes,
771            well_known_types_rust_path,
772            self.pbjson_config.as_deref(),
773        )?;
774
775        // -------- Pass 2: tonic server-only (feature + user requested) --------
776        #[cfg(feature = "tonic")]
777        if grpc {
778            Self::generate_tonic_server(
779                &out_dir,
780                &schema,
781                &proto_files,
782                &includes,
783                self.tonic_config.as_deref(),
784            )?;
785        }
786
787        // -------- Pass 3: tonic client (feature + user requested) --------
788        #[cfg(feature = "tonic-client")]
789        if grpc_client {
790            Self::generate_tonic_client(
791                &out_dir,
792                &schema,
793                &proto_files,
794                &includes,
795                self.tonic_client_config.as_deref(),
796            )?;
797        }
798
799        // Clean up descriptor file after all passes complete
800        let _ = fs::remove_file(&descriptor_path);
801
802        Ok(schema)
803    }
804
805    fn resolve_source(source: &ProtoSource) -> Result<(Vec<PathBuf>, Vec<PathBuf>)> {
806        match source {
807            ProtoSource::Directory(dir) => {
808                let mut protos = Vec::new();
809                discover_proto_files(dir, &mut protos)?;
810                if protos.is_empty() {
811                    return Err(std::io::Error::new(
812                        std::io::ErrorKind::NotFound,
813                        format!("No .proto files found in directory: {}", dir.display()),
814                    ));
815                }
816                Ok((protos, vec![dir.clone()]))
817            }
818            ProtoSource::Files { protos, includes } => {
819                if protos.is_empty() {
820                    return Err(std::io::Error::new(
821                        std::io::ErrorKind::InvalidInput,
822                        "No proto files specified",
823                    ));
824                }
825                Ok((protos.clone(), includes.clone()))
826            }
827        }
828    }
829
830    fn generate_pbjson(
831        out_dir: &str,
832        descriptor_bytes: &[u8],
833        well_known_types_rust_path: &str,
834        pbjson_config: Option<&dyn Fn(&mut pbjson_build::Builder)>,
835    ) -> Result<()> {
836        use ::prost::Message;
837        use std::fs;
838
839        // pbjson-build expects the package option to be present. An explicitly
840        // empty package generates the package-less `.serde.rs` output correctly.
841        let mut fds = prost_types::FileDescriptorSet::decode(descriptor_bytes)
842            .map_err(|e| std::io::Error::other(format!("decode descriptor: {e}")))?;
843        for file in &mut fds.file {
844            if file.package.is_none() {
845                file.package = Some(String::new());
846            }
847        }
848        let registered_descriptor_bytes = fds.encode_to_vec();
849
850        let mut pbjson_builder = pbjson_build::Builder::new();
851        pbjson_builder.out_dir(out_dir);
852        pbjson_builder.extern_path(
853            format!(".{WELL_KNOWN_TYPES_PROTO_PATH}"),
854            well_known_types_rust_path,
855        );
856        if let Some(config_fn) = pbjson_config {
857            config_fn(&mut pbjson_builder);
858        }
859        pbjson_builder
860            .register_descriptors(&registered_descriptor_bytes)
861            .map_err(|e| std::io::Error::other(format!("register descriptors: {e}")))?
862            .build(&["."]) // Generate for all packages
863            .map_err(|e| std::io::Error::other(format!("pbjson build: {e}")))?;
864
865        // Append pbjson serde implementations to main generated files
866        // pbjson-build generates {package}.serde.rs files that need to be included
867        for entry in fs::read_dir(out_dir)? {
868            let entry = entry?;
869            let path = entry.path();
870            if let Some(file_name) = path.file_name().and_then(|n| n.to_str())
871                && file_name.ends_with(".serde.rs")
872            {
873                // Prost uses `_` for package-less output while pbjson uses an
874                // empty file stem (`.serde.rs`).
875                let base_name = file_name.strip_suffix(".serde.rs").unwrap();
876                let generated_file_stem = if base_name.is_empty() { "_" } else { base_name };
877                let main_file = format!("{out_dir}/{generated_file_stem}.rs");
878
879                if std::path::Path::new(&main_file).exists() {
880                    append_generated_file(
881                        std::path::Path::new(&main_file),
882                        "// --- pbjson serde implementations ---",
883                        &path,
884                    )?;
885                }
886
887                // Remove the separate .serde.rs file. Files without a matching
888                // main file (e.g. imported packages) are orphans and would
889                // otherwise linger in the output directory.
890                let _ = fs::remove_file(&path);
891            }
892        }
893
894        Ok(())
895    }
896
897    #[cfg(feature = "tonic")]
898    fn generate_tonic_server(
899        out_dir: &str,
900        schema: &SchemaSet,
901        proto_files: &[PathBuf],
902        includes: &[PathBuf],
903        tonic_config: Option<&dyn Fn(tonic_prost_build::Builder) -> tonic_prost_build::Builder>,
904    ) -> Result<()> {
905        Self::generate_tonic_pass(
906            out_dir,
907            schema,
908            proto_files,
909            includes,
910            tonic_config,
911            TonicPass {
912                build_client: false,
913                temp_dir_name: "tonic_server",
914                banner: "// --- Tonic gRPC server stubs (extern_path reused messages) ---",
915            },
916        )
917    }
918
919    #[cfg(feature = "tonic-client")]
920    fn generate_tonic_client(
921        out_dir: &str,
922        schema: &SchemaSet,
923        proto_files: &[PathBuf],
924        includes: &[PathBuf],
925        tonic_client_config: Option<
926            &dyn Fn(tonic_prost_build::Builder) -> tonic_prost_build::Builder,
927        >,
928    ) -> Result<()> {
929        Self::generate_tonic_pass(
930            out_dir,
931            schema,
932            proto_files,
933            includes,
934            tonic_client_config,
935            TonicPass {
936                build_client: true,
937                temp_dir_name: "tonic_client",
938                banner: "// --- Tonic gRPC client stubs (extern_path reused messages) ---",
939            },
940        )
941    }
942
943    /// Run tonic-prost-build once per proto package that declares a service.
944    ///
945    /// Each run receives `extern_path` mappings whose Rust paths are relative
946    /// to that package's module, so the `super::` prefix tonic applies inside
947    /// the generated service module resolves types in any compiled package.
948    /// Only the output file for the pass's package is kept; the other
949    /// packages' files are produced by their own pass.
950    #[cfg(any(feature = "tonic", feature = "tonic-client"))]
951    fn generate_tonic_pass(
952        out_dir: &str,
953        schema: &SchemaSet,
954        proto_files: &[PathBuf],
955        includes: &[PathBuf],
956        tonic_config: Option<&dyn Fn(tonic_prost_build::Builder) -> tonic_prost_build::Builder>,
957        pass: TonicPass,
958    ) -> Result<()> {
959        use std::collections::BTreeSet;
960        use std::fs;
961
962        let service_packages: BTreeSet<&str> = schema
963            .services
964            .iter()
965            .map(|service| service.package.as_str())
966            .collect();
967
968        let temp_out_dir = format!("{}/{}", out_dir, pass.temp_dir_name);
969
970        for package in service_packages {
971            let file_stem = schema.prost().rust_package_file_stem(package);
972            fs::create_dir_all(&temp_out_dir)?;
973
974            let mut builder = tonic_prost_build::configure();
975
976            // Apply user's tonic configuration
977            if let Some(config_fn) = tonic_config {
978                builder = config_fn(builder);
979            }
980
981            // Apply internal config (takes precedence). When a user extern
982            // override covers the well-known types, prost's default
983            // `::prost_types` externs must be disabled (they would collide
984            // with the override) by compiling WKTs, and the override supplies
985            // the extern mapping instead.
986            let overrides_cover_wkt = schema
987                .extern_overrides
988                .iter()
989                .any(|(proto_path, _)| covers_package(proto_path, "google.protobuf"));
990            builder = builder
991                .build_client(pass.build_client)
992                .build_server(!pass.build_client)
993                .compile_well_known_types(overrides_cover_wkt)
994                .out_dir(&temp_out_dir);
995
996            // Register user extern overrides (absolute external paths), then
997            // extern_path mappings for the types generated in pass 1
998            for (proto_path, rust_path) in &schema.extern_overrides {
999                builder = builder.extern_path(format!(".{proto_path}"), rust_path.clone());
1000            }
1001            for tr in schema.prost().type_path_mappings(package) {
1002                builder = builder.extern_path(tr.proto_path, tr.rust_path);
1003            }
1004            builder.compile_protos(proto_files, includes)?;
1005
1006            // Append this package's stubs to its first-pass file (creating it
1007            // for service-only packages where prost emitted no file).
1008            let tonic_file = format!("{}/{}.rs", temp_out_dir, file_stem);
1009            if std::path::Path::new(&tonic_file).exists() {
1010                append_generated_file(
1011                    std::path::Path::new(&format!("{}/{}.rs", out_dir, file_stem)),
1012                    pass.banner,
1013                    std::path::Path::new(&tonic_file),
1014                )?;
1015            }
1016
1017            // Clean up temporary tonic artifacts
1018            let _ = fs::remove_dir_all(&temp_out_dir);
1019        }
1020
1021        Ok(())
1022    }
1023}
1024
1025/// Static configuration distinguishing the tonic server and client passes.
1026#[cfg(any(feature = "tonic", feature = "tonic-client"))]
1027struct TonicPass {
1028    build_client: bool,
1029    temp_dir_name: &'static str,
1030    banner: &'static str,
1031}
1032
1033/// Whether the dotted proto package prefix `prefix` covers `package` (equal
1034/// to it or an ancestor of it).
1035#[cfg(any(feature = "tonic", feature = "tonic-client"))]
1036fn covers_package(prefix: &str, package: &str) -> bool {
1037    package == prefix
1038        || package
1039            .strip_prefix(prefix)
1040            .is_some_and(|rest| rest.starts_with('.'))
1041        || prefix
1042            .strip_prefix(package)
1043            .is_some_and(|rest| rest.starts_with('.'))
1044}
1045
1046/// Create a builder without a proto source.
1047///
1048/// Use [`compile_dir()`](CompileBuilder::compile_dir) or
1049/// [`compile_protos()`](CompileBuilder::compile_protos) to set the source
1050/// before calling [`compile()`](CompileBuilder::compile).
1051///
1052/// # Example
1053///
1054/// ```rust,ignore
1055/// fn main() -> Result<(), Box<dyn std::error::Error>> {
1056///     connectrpc_axum_build::builder()
1057///         .include_file("protos.rs")
1058///         .compile_dir("proto")
1059///         .compile()?;
1060///     Ok(())
1061/// }
1062/// ```
1063pub fn builder() -> CompileBuilder {
1064    CompileBuilder {
1065        source: NoSource,
1066        out_dir: None,
1067        include_file: None,
1068        extern_reexports: Vec::new(),
1069
1070        #[cfg(feature = "fetch-protoc")]
1071        protoc_path: None,
1072        prost_config: None,
1073        pbjson_config: None,
1074        #[cfg(feature = "tonic")]
1075        tonic_config: None,
1076        #[cfg(feature = "tonic-client")]
1077        tonic_client_config: None,
1078        _marker: PhantomData,
1079    }
1080}
1081
1082/// Convenience function that auto-discovers all .proto files in the directory
1083/// and returns a builder ready to compile.
1084///
1085/// This provides the best developer experience by only requiring the proto directory path.
1086///
1087/// # Examples
1088///
1089/// ```rust,no_run
1090/// fn main() -> Result<(), Box<dyn std::error::Error>> {
1091///     connectrpc_axum_build::compile_dir("proto").compile()?;
1092///     Ok(())
1093/// }
1094/// ```
1095///
1096/// With custom configuration:
1097/// ```rust,no_run
1098/// fn main() -> Result<(), Box<dyn std::error::Error>> {
1099///     connectrpc_axum_build::compile_dir("proto")
1100///         .with_prost_config(|config| {
1101///             config.type_attribute(".", "#[derive(Debug)]");
1102///         })
1103///         .compile()?;
1104///     Ok(())
1105/// }
1106/// ```
1107pub fn compile_dir(dir: impl AsRef<Path>) -> CompileBuilder<WithSource> {
1108    CompileBuilder {
1109        source: WithSource(ProtoSource::Directory(dir.as_ref().to_path_buf())),
1110        out_dir: None,
1111        include_file: None,
1112        extern_reexports: Vec::new(),
1113
1114        #[cfg(feature = "fetch-protoc")]
1115        protoc_path: None,
1116        prost_config: None,
1117        pbjson_config: None,
1118        #[cfg(feature = "tonic")]
1119        tonic_config: None,
1120        #[cfg(feature = "tonic-client")]
1121        tonic_client_config: None,
1122        _marker: PhantomData,
1123    }
1124}
1125
1126/// Convenience function that compiles specific proto files with explicit include directories.
1127///
1128/// # Arguments
1129///
1130/// * `protos` - Proto files to compile
1131/// * `includes` - Directories to search for imports
1132///
1133/// # Example
1134///
1135/// ```rust,ignore
1136/// fn main() -> Result<(), Box<dyn std::error::Error>> {
1137///     connectrpc_axum_build::compile_protos(
1138///         &["proto/service.proto", "proto/messages.proto"],
1139///         &["proto", "third_party"],
1140///     ).compile()?;
1141///     Ok(())
1142/// }
1143/// ```
1144pub fn compile_protos<P: AsRef<Path>>(protos: &[P], includes: &[P]) -> CompileBuilder<WithSource> {
1145    CompileBuilder {
1146        source: WithSource(ProtoSource::Files {
1147            protos: protos.iter().map(|p| p.as_ref().to_path_buf()).collect(),
1148            includes: includes.iter().map(|p| p.as_ref().to_path_buf()).collect(),
1149        }),
1150        out_dir: None,
1151        include_file: None,
1152        extern_reexports: Vec::new(),
1153
1154        #[cfg(feature = "fetch-protoc")]
1155        protoc_path: None,
1156        prost_config: None,
1157        pbjson_config: None,
1158        #[cfg(feature = "tonic")]
1159        tonic_config: None,
1160        #[cfg(feature = "tonic-client")]
1161        tonic_client_config: None,
1162        _marker: PhantomData,
1163    }
1164}
1165
1166fn discover_proto_files(dir: &Path, proto_files: &mut Vec<std::path::PathBuf>) -> Result<()> {
1167    if !dir.is_dir() {
1168        return Err(std::io::Error::new(
1169            std::io::ErrorKind::NotFound,
1170            format!("Directory not found: {}", dir.display()),
1171        ));
1172    }
1173
1174    for entry in std::fs::read_dir(dir)? {
1175        let entry = entry?;
1176        let path = entry.path();
1177        let file_type = entry.file_type()?;
1178
1179        if file_type.is_dir() {
1180            // Recursively search subdirectories (but not symlinked ones, which
1181            // could introduce cycles)
1182            discover_proto_files(&path, proto_files)?;
1183        } else if path.extension().and_then(|s| s.to_str()) == Some("proto") && path.is_file() {
1184            proto_files.push(path);
1185        }
1186    }
1187
1188    Ok(())
1189}