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(®istered_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}