Skip to main content

connectrpc_build/
lib.rs

1//! Build-time integration for connectrpc.
2//!
3//! Use this crate in `build.rs` to compile `.proto` files into Rust code at
4//! build time. It shells out to `protoc` (or `buf`, or reads a precompiled
5//! `FileDescriptorSet`) to obtain descriptors, then runs
6//! [`connectrpc_codegen`] to emit buffa message types plus ConnectRPC
7//! service traits and clients into `$OUT_DIR`.
8//!
9//! # Example
10//!
11//! ```rust,ignore
12//! // build.rs
13//! fn main() {
14//!     connectrpc_build::Config::new()
15//!         .files(&["proto/my_service.proto"])
16//!         .includes(&["proto/"])
17//!         .include_file("_connectrpc.rs")
18//!         .compile()
19//!         .unwrap();
20//! }
21//! ```
22//!
23//! ```rust,ignore
24//! // lib.rs
25//! connectrpc::include_generated!();
26//! ```
27//!
28//! # Requirements
29//!
30//! Requires `protoc` on `PATH` (or set via `PROTOC`). To use `buf` instead,
31//! call [`Config::use_buf`]. To avoid both, precompile a `FileDescriptorSet`
32//! once and ship it alongside your source via [`Config::descriptor_set`].
33//!
34//! To embed the compiled `FileDescriptorSet` in your binary — for example
35//! to back gRPC server reflection — see [`Config::emit_descriptor_set`].
36
37use std::path::{Path, PathBuf};
38use std::process::Command;
39
40use anyhow::{Context, Result, anyhow, bail};
41use buffa::Message;
42use buffa_codegen::generated::descriptor::FileDescriptorSet;
43use connectrpc_codegen::codegen::{self, Options};
44
45pub use connectrpc_codegen::codegen::CodeGenConfig;
46pub use connectrpc_codegen::codegen::EncodableImpls;
47
48/// How to acquire a `FileDescriptorSet` from `.proto` files.
49#[derive(Debug, Clone, Default)]
50enum DescriptorSource {
51    /// Invoke `protoc` (default). Requires `protoc` on PATH or `PROTOC` env var.
52    #[default]
53    Protoc,
54    /// Invoke `buf build --as-file-descriptor-set`. Requires `buf` on PATH.
55    Buf,
56    /// Read a pre-built `FileDescriptorSet` from a file.
57    Precompiled(PathBuf),
58}
59
60/// Builder for configuring and running connectrpc code generation.
61///
62/// See the [crate-level docs](crate) for a worked example.
63pub struct Config {
64    files: Vec<PathBuf>,
65    includes: Vec<PathBuf>,
66    out_dir: Option<PathBuf>,
67    descriptor_source: DescriptorSource,
68    include_file: Option<String>,
69    emit_descriptor_set: Option<String>,
70    emit_rerun_directives: bool,
71    options: Options,
72}
73
74impl Config {
75    /// Create a new configuration with defaults.
76    pub fn new() -> Self {
77        Self {
78            files: Vec::new(),
79            includes: Vec::new(),
80            out_dir: None,
81            descriptor_source: DescriptorSource::default(),
82            include_file: None,
83            emit_descriptor_set: None,
84            emit_rerun_directives: true,
85            options: Options::default(),
86        }
87    }
88
89    /// Add `.proto` files to compile.
90    #[must_use]
91    pub fn files(mut self, files: &[impl AsRef<Path>]) -> Self {
92        self.files
93            .extend(files.iter().map(|f| f.as_ref().to_path_buf()));
94        self
95    }
96
97    /// Add include directories for protoc to search for imports.
98    ///
99    /// Ignored when using [`Config::use_buf`] (buf resolves imports via
100    /// `buf.yaml`).
101    #[must_use]
102    pub fn includes(mut self, includes: &[impl AsRef<Path>]) -> Self {
103        self.includes
104            .extend(includes.iter().map(|i| i.as_ref().to_path_buf()));
105        self
106    }
107
108    /// Set the output directory. Defaults to `$OUT_DIR`.
109    #[must_use]
110    pub fn out_dir(mut self, dir: impl Into<PathBuf>) -> Self {
111        self.out_dir = Some(dir.into());
112        self
113    }
114
115    /// Emit `cargo:rerun-if-changed=` directives to stdout (default: `true`).
116    ///
117    /// Set to `false` when running outside a Cargo `build.rs` context (e.g.
118    /// from a Bazel genrule or a standalone host tool) where the directives
119    /// are noise on stdout rather than instructions to a build system.
120    #[must_use]
121    pub fn emit_rerun_directives(mut self, enabled: bool) -> Self {
122        self.emit_rerun_directives = enabled;
123        self
124    }
125
126    /// Honor `features.utf8_validation = NONE` by emitting `Vec<u8>`/`&[u8]`
127    /// for such string fields. See [`CodeGenConfig::strict_utf8_mapping`].
128    #[must_use]
129    pub fn strict_utf8_mapping(mut self, enabled: bool) -> Self {
130        self.options.buffa.strict_utf8_mapping = enabled;
131        self
132    }
133
134    /// Emit `serde` derives and proto3 JSON helpers on generated message
135    /// types (default: true).
136    ///
137    /// Disable for **proto-only** builds that never speak the Connect JSON
138    /// codec: message types are emitted without
139    /// `#[derive(serde::Serialize, serde::Deserialize)]`, cutting code size
140    /// and serde compile time. Pair it with `connectrpc`'s
141    /// `default-features = false` (the `json` cargo feature off) so the
142    /// runtime drops its matching serde bounds — proto-only generated code
143    /// only compiles against a proto-only runtime, and a JSON request to such
144    /// a server returns `Unimplemented`. With `json` left on, the runtime
145    /// still requires these derives. See [`CodeGenConfig::generate_json`].
146    #[must_use]
147    pub fn generate_json(mut self, enabled: bool) -> Self {
148        self.options.buffa.generate_json = enabled;
149        self
150    }
151
152    /// Emit the per-file `register_types(&mut TypeRegistry)` aggregator
153    /// (default: true).
154    ///
155    /// Set to `false` when the generated files are `include!`d into the
156    /// same module — the identically-named functions would otherwise
157    /// collide. See [`CodeGenConfig::emit_register_fn`].
158    #[must_use]
159    pub fn emit_register_fn(mut self, enabled: bool) -> Self {
160        self.options.buffa.emit_register_fn = enabled;
161        self
162    }
163
164    /// Emit one `<dotted.pkg>.rs` per proto package instead of the
165    /// per-proto split + per-package stitcher (default: `false`).
166    ///
167    /// Under this layout the connect service stubs are inlined directly
168    /// into buffa's single `<dotted.pkg>.rs` `PackageMod` per package — no
169    /// `<stem>.__connect.rs` companion files, no per-proto buffa content
170    /// files, and no `<pkg>.mod.rs` stitchers are written. Combine with
171    /// [`Config::include_file`] as usual: the include file wires
172    /// `PackageMod` entries by `file.name`, so the new filename
173    /// (`<dotted.pkg>.rs` instead of `<pkg>.mod.rs`) is picked up
174    /// transparently — your `lib.rs` still reads
175    /// `connectrpc::include_generated!()` with no change. If you instead
176    /// `include!` or `#[path = ...]`-mount per-proto files directly,
177    /// migrate to the include file or to the per-package filenames first;
178    /// the per-proto files no longer exist under this layout.
179    ///
180    /// Match this to the `file_per_package` buf plugin option when
181    /// generating Buf Schema Registry cargo SDKs or any consumer that
182    /// synthesises a module tree from `<dotted.package>.rs` filenames
183    /// (`tonic`'s convention). See [`CodeGenConfig::file_per_package`].
184    #[must_use]
185    pub fn file_per_package(mut self, enabled: bool) -> Self {
186        self.options.buffa.file_per_package = enabled;
187        self
188    }
189
190    /// Prefix every generated `FooClient<T>` struct and its `impl` block
191    /// with `#[cfg(feature = "client")]` (default: `false`). Use
192    /// [`Config::client_feature_name`] to gate on a feature other than
193    /// `"client"` — note that calling `client_feature_name` re-enables
194    /// gating, so order it before `gate_client_feature(false)` if you
195    /// need to set a name but leave gating off.
196    ///
197    /// Opt in when you want a server-only build of your crate to drop
198    /// the `connectrpc/client` transport stack from its dependency
199    /// graph. The consumer crate then declares the gate's Cargo feature
200    /// and forwards it to `connectrpc/client`; see the `# Client-side cfg
201    /// gate` section in [`connectrpc_codegen::codegen::generate`]'s
202    /// docs for the minimal pattern. With the option off (the default),
203    /// generated client items are unconditional — external consumers
204    /// don't have to declare any Cargo feature.
205    #[must_use]
206    pub fn gate_client_feature(mut self, enabled: bool) -> Self {
207        self.options.gate_client_feature = enabled;
208        self
209    }
210
211    /// Select which messages get `::connectrpc::Encodable` view impls
212    /// (default: [`EncodableImpls::Outputs`] — RPC output types only).
213    ///
214    /// Pass [`EncodableImpls::AllMessages`] when this crate's message
215    /// types are consumed by *other* crates' services (a shared proto
216    /// crate in a multi-crate split). Rust's orphan rules require the
217    /// `impl Encodable<M> for MView<'_>` blocks to live in the crate that
218    /// defines the view types, so a downstream service crate cannot emit
219    /// them itself — without this, its handlers must return owned messages
220    /// or `PreEncoded::from_view` for these types instead of views.
221    ///
222    /// Note that `connectrpc-build` drives the **unified** generation path,
223    /// which ignores `extern_paths` — the consuming service crates of the
224    /// split must be generated through the `protoc-gen-connect-rust` buf
225    /// plugin with `extern_path=<pkg>=::this_crate::...` so their stubs
226    /// reference this crate's types. Mirrors the plugin's
227    /// `encodable_impls=all_messages` option; see
228    /// [`connectrpc_codegen::codegen::Options::encodable_impls`].
229    #[must_use]
230    pub fn encodable_impls(mut self, mode: EncodableImpls) -> Self {
231        self.options.encodable_impls = mode;
232        self
233    }
234
235    /// Enable [`Config::gate_client_feature`] and set the Cargo feature name
236    /// it gates on (default: `"client"`).
237    ///
238    /// Use this when the generated crate exposes its client surface under a
239    /// different feature name, such as `grpc-client` or `transport`. Calling
240    /// this implies `gate_client_feature(true)`, mirroring the plugin's
241    /// `gate_client_feature=<name>` form, so you don't need both calls.
242    #[must_use]
243    pub fn client_feature_name(mut self, feature: impl Into<String>) -> Self {
244        self.options.gate_client_feature = true;
245        self.options.client_feature_name = feature.into();
246        self
247    }
248
249    /// Replace the underlying buffa [`CodeGenConfig`] wholesale.
250    ///
251    /// Any buffa knob not surfaced as a builder method here can be set this
252    /// way. The convenience builders above remain available for the common
253    /// cases. `generate_views` is forced to `true` regardless (service
254    /// stubs require view types); see [`Options::buffa`].
255    ///
256    /// Calls to the convenience builders above made *before* this method
257    /// are discarded; calls made *after* override individual fields in the
258    /// supplied config.
259    #[must_use]
260    pub fn buffa_config(mut self, config: CodeGenConfig) -> Self {
261        self.options.buffa = config;
262        self
263    }
264
265    /// Invoke `buf build` instead of `protoc`.
266    ///
267    /// Requires `buf` on PATH. Uses buf's dependency resolution (BSR modules)
268    /// and `buf.yaml` configuration; [`Config::includes`] is ignored. When
269    /// using buf, [`Config::files`] must contain proto-relative names as
270    /// they appear in the buf module (e.g. `"my/service.proto"`), not
271    /// filesystem paths.
272    #[must_use]
273    pub fn use_buf(mut self) -> Self {
274        self.descriptor_source = DescriptorSource::Buf;
275        self
276    }
277
278    /// Read a precompiled `FileDescriptorSet` from disk instead of invoking
279    /// a compiler.
280    ///
281    /// Produce the file once with `protoc --descriptor_set_out=... --include_imports`
282    /// or `buf build --as-file-descriptor-set -o ...`, then ship it with
283    /// your source.
284    ///
285    /// [`Config::files`] selects which files in the set to generate code for.
286    /// **These must be the proto-relative names as they appear in the
287    /// descriptor set** (e.g. `"my/service.proto"`), not filesystem paths.
288    /// See the `.proto` file's `name` field in the descriptor, which protoc
289    /// sets to the path relative to `--proto_path`.
290    #[must_use]
291    pub fn descriptor_set(mut self, path: impl Into<PathBuf>) -> Self {
292        self.descriptor_source = DescriptorSource::Precompiled(path.into());
293        self
294    }
295
296    /// Also write the input `FileDescriptorSet` (the full set handed to
297    /// codegen, not just the files selected for generation) to
298    /// `<out_dir>/<name>` as wire-format bytes. `name` must be a bare file
299    /// name — no path separators.
300    ///
301    /// The set carries the full transitive import closure for every descriptor
302    /// source (`protoc --include_imports`, `buf --as-file-descriptor-set`, or a
303    /// precompiled set), so it is ready to back `grpc.reflection.v1.ServerReflection`
304    /// for clients such as `grpcurl`. Pair it with `include_bytes!`:
305    ///
306    /// ```ignore
307    /// // build.rs
308    /// connectrpc_build::Config::new()
309    ///     .files(&["proto/svc.proto"])
310    ///     .includes(&["proto/"])
311    ///     .emit_descriptor_set("svc_descriptor.bin")
312    ///     .compile()?;
313    /// // src/lib.rs
314    /// pub const FILE_DESCRIPTOR_SET: &[u8] =
315    ///     include_bytes!(concat!(env!("OUT_DIR"), "/svc_descriptor.bin"));
316    /// ```
317    ///
318    /// The inverse of [`Config::descriptor_set`], which *reads* a precompiled
319    /// set; this *writes* the one connectrpc-build already computed, so build
320    /// scripts no longer need a second `protoc --descriptor_set_out` pass.
321    #[must_use]
322    pub fn emit_descriptor_set(mut self, name: impl Into<String>) -> Self {
323        self.emit_descriptor_set = Some(name.into());
324        self
325    }
326
327    /// Emit an `include!`-based module tree file alongside the per-file
328    /// `.rs` outputs.
329    ///
330    /// The file contains nested `pub mod` blocks matching the proto package
331    /// hierarchy, each `include!`-ing the relevant generated file. Include
332    /// it from your crate root:
333    ///
334    /// ```rust,ignore
335    /// connectrpc::include_generated!();
336    /// ```
337    #[must_use]
338    pub fn include_file(mut self, name: impl Into<String>) -> Self {
339        self.include_file = Some(name.into());
340        self
341    }
342
343    /// Run code generation and write output files.
344    ///
345    /// # Errors
346    ///
347    /// - `$OUT_DIR` is unset and no `out_dir` was configured
348    /// - `protoc` or `buf` is not on `PATH` (when using those sources)
349    /// - the compiler exits non-zero (syntax error, missing import, ...)
350    /// - a precompiled descriptor set cannot be read or decoded
351    /// - codegen fails (unsupported proto feature)
352    /// - the output directory cannot be created or written to
353    /// - [`Config::emit_descriptor_set`] was given a name containing path
354    ///   separators, or the descriptor set cannot be written
355    pub fn compile(self) -> Result<()> {
356        // When out_dir() is explicitly set, emit sibling-relative include!
357        // paths — the include file lives next to the generated files and
358        // is referenced as a module. When defaulted from $OUT_DIR, emit
359        // the env!("OUT_DIR") form for the build.rs/include! workflow.
360        let relative_includes = self.out_dir.is_some();
361        let out_dir = match self.out_dir {
362            Some(d) => d,
363            None => std::env::var_os("OUT_DIR")
364                .map(PathBuf::from)
365                .context("OUT_DIR is not set and no out_dir() was configured")?,
366        };
367
368        // 1. Acquire descriptor bytes and resolve files_to_generate.
369        //
370        // `FileDescriptorProto.name` is the path relative to the include
371        // directory, not the filesystem path. For the Protoc mode we strip
372        // the longest matching include prefix to recover this name. For
373        // Buf and Precompiled modes, the user must already provide
374        // proto-relative names in .files() (see docs on use_buf() and
375        // descriptor_set()) so we pass them through as-is.
376        let (descriptor_bytes, files_to_generate) = match &self.descriptor_source {
377            DescriptorSource::Protoc => {
378                let bytes = run_protoc(&self.files, &self.includes)?;
379                // Sort includes longest-first so nested prefixes like
380                // ["proto/", "proto/vendor/"] strip the most specific one.
381                let mut includes = self.includes.clone();
382                includes.sort_by_key(|p| std::cmp::Reverse(p.as_os_str().len()));
383                let files = self
384                    .files
385                    .iter()
386                    .map(|f| strip_include_prefix(f, &includes))
387                    .filter(|s| !s.is_empty())
388                    .collect();
389                (bytes, files)
390            }
391            DescriptorSource::Buf => {
392                let bytes = run_buf(&self.files)?;
393                (bytes, proto_relative_names(&self.files))
394            }
395            DescriptorSource::Precompiled(p) => {
396                let bytes = std::fs::read(p)
397                    .with_context(|| format!("failed to read descriptor set '{}'", p.display()))?;
398                (bytes, proto_relative_names(&self.files))
399            }
400        };
401        let fds = FileDescriptorSet::decode_from_slice(&descriptor_bytes)
402            .map_err(|e| anyhow!("failed to decode FileDescriptorSet: {e}"))?;
403
404        // 3. Generate.
405        let generated = codegen::generate_files(&fds.file, &files_to_generate, &self.options)?;
406
407        // 4. Write per-file outputs and collect (name, package) pairs for
408        //    PackageMod files only — the per-package stitcher `include!`s
409        //    the five content files itself, so the module tree only wires
410        //    stitchers.
411        std::fs::create_dir_all(&out_dir)
412            .with_context(|| format!("failed to create out_dir '{}'", out_dir.display()))?;
413
414        // Emit the parsed descriptor set for gRPC server reflection, if requested.
415        // `descriptor_bytes` already carries the full import closure for every
416        // descriptor source, so the written set is reflection-ready as-is.
417        if let Some(name) = &self.emit_descriptor_set {
418            // `<out_dir>/<name>` is the documented contract; a separator or
419            // absolute path would silently escape it via `Path::join`.
420            if Path::new(name).components().count() != 1 || Path::new(name).is_absolute() {
421                bail!(
422                    "emit_descriptor_set name must be a bare file name \
423                     (no path separators), got {name:?}"
424                );
425            }
426            let target = out_dir.join(name);
427            write_if_changed(&target, &descriptor_bytes)
428                .with_context(|| format!("failed to write descriptor set {}", target.display()))?;
429        }
430
431        let mut entries: Vec<(String, String)> = Vec::new();
432        for file in &generated {
433            let path = out_dir.join(&file.name);
434            if let Some(parent) = path.parent() {
435                std::fs::create_dir_all(parent)?;
436            }
437            write_if_changed(&path, file.content.as_bytes())?;
438            if file.kind == codegen::GeneratedFileKind::PackageMod {
439                entries.push((file.name.clone(), file.package.clone()));
440            }
441        }
442
443        // 5. Optionally emit the module-tree include file.
444        if let Some(ref include_name) = self.include_file {
445            let include_src = generate_include_file(&entries, relative_includes);
446            let include_path = out_dir.join(include_name);
447            write_if_changed(&include_path, include_src.as_bytes())?;
448        }
449
450        // 6. Cargo re-run triggers. Skipped entirely for non-Cargo callers.
451        // In Precompiled mode `self.files` holds proto-relative names (per
452        // the docs on `descriptor_set()`), not on-disk paths; emitting
453        // `rerun-if-changed` for them points cargo at missing files and
454        // forces a rebuild on every invocation. The `.pb` path is the only
455        // real input in that mode.
456        if !self.emit_rerun_directives {
457            return Ok(());
458        }
459        match &self.descriptor_source {
460            DescriptorSource::Precompiled(p) => {
461                println!("cargo:rerun-if-changed={}", p.display());
462            }
463            // Both Buf and Precompiled modes use proto-relative names (not
464            // filesystem paths) in `.files()`. Emitting `rerun-if-changed`
465            // for those would point cargo at non-existent files and force a
466            // rebuild every invocation.
467            DescriptorSource::Buf => {}
468            DescriptorSource::Protoc => {
469                for f in &self.files {
470                    println!("cargo:rerun-if-changed={}", f.display());
471                }
472            }
473        }
474
475        Ok(())
476    }
477}
478
479impl Default for Config {
480    fn default() -> Self {
481        Self::new()
482    }
483}
484
485/// Write `content` to `path` only if the file doesn't already exist with
486/// identical content. Cargo's rebuild decision for `include!`-ed files is
487/// mtime-based, so an unconditional write here would cascade into
488/// recompiling every downstream crate whenever any `.proto` is touched.
489fn write_if_changed(path: &Path, content: &[u8]) -> std::io::Result<()> {
490    if let Ok(existing) = std::fs::read(path)
491        && existing == content
492    {
493        return Ok(());
494    }
495    std::fs::write(path, content)
496}
497
498/// Run `protoc` and return the serialized `FileDescriptorSet`.
499fn run_protoc(files: &[PathBuf], includes: &[PathBuf]) -> Result<Vec<u8>> {
500    let protoc = std::env::var("PROTOC").unwrap_or_else(|_| "protoc".to_string());
501
502    let out = tempfile::NamedTempFile::new().context("failed to create tempfile for protoc")?;
503    let out_path = out.path().to_path_buf();
504
505    let mut cmd = Command::new(&protoc);
506    cmd.arg("--include_imports");
507    cmd.arg(format!("--descriptor_set_out={}", out_path.display()));
508    for inc in includes {
509        cmd.arg(format!("--proto_path={}", inc.display()));
510    }
511    for f in files {
512        cmd.arg(f.as_os_str());
513    }
514
515    let output = cmd
516        .output()
517        .with_context(|| format!("failed to spawn protoc ('{protoc}')"))?;
518    if !output.status.success() {
519        bail!("protoc failed: {}", String::from_utf8_lossy(&output.stderr));
520    }
521
522    std::fs::read(&out_path).context("failed to read protoc descriptor output")
523}
524
525/// Run `buf build --as-file-descriptor-set` and return the serialized bytes.
526///
527/// Includes are intentionally NOT passed: buf's `--path` flag is a file
528/// filter, not an import path like protoc's `--proto_path`. Passing include
529/// directories as `--path` would restrict the output in unintended ways.
530/// buf resolves imports via `buf.yaml`.
531fn run_buf(files: &[PathBuf]) -> Result<Vec<u8>> {
532    let out = tempfile::NamedTempFile::new().context("failed to create tempfile for buf")?;
533    let out_path = out.path().to_path_buf();
534
535    let mut cmd = Command::new("buf");
536    cmd.arg("build")
537        .arg("--as-file-descriptor-set")
538        .arg("-o")
539        .arg(&out_path);
540    for f in files {
541        cmd.arg("--path").arg(f.as_os_str());
542    }
543
544    let output = cmd.output().context("failed to spawn buf")?;
545    if !output.status.success() {
546        bail!(
547            "buf build failed: {}",
548            String::from_utf8_lossy(&output.stderr)
549        );
550    }
551
552    std::fs::read(&out_path).context("failed to read buf descriptor output")
553}
554
555/// Strip the longest matching include prefix from a filesystem path to
556/// recover the proto-relative name protoc stores in `FileDescriptorProto.name`.
557///
558/// Falls back to the bare file name if no prefix matches. Callers must
559/// pre-sort `includes` longest-first so nested include directories are
560/// matched correctly.
561fn strip_include_prefix(f: &Path, includes: &[PathBuf]) -> String {
562    for inc in includes {
563        if let Ok(rel) = f.strip_prefix(inc)
564            && let Some(s) = rel.to_str()
565        {
566            return s.to_string();
567        }
568    }
569    f.file_name()
570        .and_then(|n| n.to_str())
571        .unwrap_or_default()
572        .to_string()
573}
574
575/// Convert `.files()` paths to strings verbatim for Buf/Precompiled modes
576/// where the user supplies proto-relative names directly (no include prefix
577/// to strip).
578fn proto_relative_names(files: &[PathBuf]) -> Vec<String> {
579    files
580        .iter()
581        .filter_map(|f| f.to_str().map(str::to_string))
582        .filter(|s| !s.is_empty())
583        .collect()
584}
585
586/// Build an `include!`-based module-tree file.
587///
588/// Given `[("my.pkg.thing.rs", "my.pkg"), ...]`, produce nested
589/// `pub mod my { pub mod pkg { include!(...); } }`.
590///
591/// When `relative` is false (the `$OUT_DIR` workflow), emit
592/// `include!(concat!(env!("OUT_DIR"), "/my.pkg.thing.rs"))`.
593/// When `relative` is true (explicit `out_dir()`), the include file and the
594/// generated files are siblings, so emit `include!("my.pkg.thing.rs")` —
595/// `include!` resolves relative to the including file.
596fn generate_include_file(entries: &[(String, String)], relative: bool) -> String {
597    use std::collections::BTreeMap;
598    use std::fmt::Write as _;
599
600    #[derive(Default)]
601    struct Node {
602        files: Vec<String>,
603        children: BTreeMap<String, Node>,
604    }
605
606    let mut root = Node::default();
607    for (file_name, package) in entries {
608        let mut node = &mut root;
609        if !package.is_empty() {
610            for seg in package.split('.') {
611                node = node.children.entry(seg.to_string()).or_default();
612            }
613        }
614        node.files.push(file_name.clone());
615    }
616
617    fn emit(out: &mut String, node: &Node, depth: usize, relative: bool) {
618        let indent = "    ".repeat(depth);
619        for f in &node.files {
620            if relative {
621                writeln!(out, r#"{indent}include!("{f}");"#).unwrap();
622            } else {
623                writeln!(
624                    out,
625                    r#"{indent}include!(concat!(env!("OUT_DIR"), "/{f}"));"#
626                )
627                .unwrap();
628            }
629        }
630        for (name, child) in &node.children {
631            let ident = buffa_codegen::idents::escape_mod_ident(name);
632            // The `pub mod <pkg>` tree wraps buffa's per-proto split
633            // output (Owned/View/Oneof/Ext + the PackageMod stitcher)
634            // plus our own `__connect.rs` companions. The per-proto
635            // content files have no `#[allow(...)]` of their own —
636            // buffa's `package_mod_allow_attr()` is scoped to `__buffa`
637            // and `protoc-gen-buffa-packaging` covers the rest with an
638            // inner `#![allow(...)]` that doesn't apply here — so the
639            // suppression set must be the union of
640            // `buffa_codegen::ALLOW_LINTS` and the lints connect-rust
641            // output trips. Sourcing from `ALLOW_LINTS` keeps the two
642            // in lockstep when buffa adds entries.
643            //
644            // `impl_trait_redundant_captures`: the `use<'a, Self>` precise-
645            // capturing clause on trait method RPITs is required for
646            // edition-2021 consumers (which capture only `'static` by
647            // default) but redundant under edition 2024. Codegen targets
648            // both editions and cannot know the consumer's at write time.
649            let allow_lints = buffa_codegen::ALLOW_LINTS
650                .iter()
651                .copied()
652                .chain(["impl_trait_redundant_captures"])
653                .collect::<Vec<_>>()
654                .join(", ");
655            writeln!(out, "{indent}#[allow({allow_lints})]").unwrap();
656            writeln!(out, "{indent}pub mod {ident} {{").unwrap();
657            writeln!(out, "{indent}    use super::*;").unwrap();
658            emit(out, child, depth + 1, relative);
659            writeln!(out, "{indent}}}").unwrap();
660        }
661    }
662
663    let mut out = String::new();
664    writeln!(out, "// @generated by connectrpc-build. DO NOT EDIT.").unwrap();
665    writeln!(out).unwrap();
666    emit(&mut out, &root, 0, relative);
667    out
668}
669
670#[cfg(test)]
671mod tests {
672    use super::*;
673
674    #[test]
675    fn include_file_nests_packages() {
676        let entries = vec![
677            ("my.pkg.svc.rs".into(), "my.pkg".into()),
678            ("my.other.rs".into(), "my".into()),
679            ("root.rs".into(), String::new()),
680        ];
681        let out = generate_include_file(&entries, false);
682
683        assert!(
684            out.contains("// @generated by connectrpc-build"),
685            "missing header: {out}"
686        );
687        // Root-level file has no wrapper.
688        assert!(
689            out.contains(r#"include!(concat!(env!("OUT_DIR"), "/root.rs"));"#),
690            "missing root include: {out}"
691        );
692        // my.pkg.svc.rs is nested two levels deep.
693        assert!(out.contains("pub mod my {"), "missing mod my: {out}");
694        assert!(out.contains("pub mod pkg {"), "missing mod pkg: {out}");
695        assert!(
696            out.contains(r#"include!(concat!(env!("OUT_DIR"), "/my.pkg.svc.rs"));"#),
697            "missing nested include: {out}"
698        );
699        // my.other.rs is one level deep (under mod my).
700        assert!(
701            out.contains(r#"include!(concat!(env!("OUT_DIR"), "/my.other.rs"));"#),
702            "missing my.other include: {out}"
703        );
704    }
705
706    #[test]
707    fn include_file_relative_mode() {
708        let entries = vec![
709            ("my.pkg.svc.rs".into(), "my.pkg".into()),
710            ("root.rs".into(), String::new()),
711        ];
712        let out = generate_include_file(&entries, true);
713
714        // Relative mode uses bare sibling paths, no env!/concat!.
715        assert!(
716            out.contains(r#"include!("root.rs");"#),
717            "missing relative root include: {out}"
718        );
719        assert!(
720            out.contains(r#"include!("my.pkg.svc.rs");"#),
721            "missing relative nested include: {out}"
722        );
723        assert!(
724            !out.contains("env!"),
725            "relative mode should not emit env!: {out}"
726        );
727        assert!(
728            !out.contains("concat!"),
729            "relative mode should not emit concat!: {out}"
730        );
731        // Module tree is the same regardless of include form.
732        assert!(out.contains("pub mod my {"), "missing mod my: {out}");
733        assert!(out.contains("pub mod pkg {"), "missing mod pkg: {out}");
734    }
735
736    #[test]
737    fn include_file_escapes_keywords() {
738        let entries = vec![("type.match.svc.rs".into(), "type.match".into())];
739        let out = generate_include_file(&entries, false);
740        assert!(out.contains("pub mod r#type {"), "expected r#type: {out}");
741        assert!(out.contains("pub mod r#match {"), "expected r#match: {out}");
742    }
743
744    #[test]
745    fn config_builder_chain() {
746        let cfg = Config::new()
747            .files(&["a.proto", "b.proto"])
748            .includes(&["proto/"])
749            .strict_utf8_mapping(true)
750            .generate_json(false)
751            .emit_register_fn(false)
752            .gate_client_feature(true)
753            .client_feature_name("grpc-client")
754            .encodable_impls(EncodableImpls::AllMessages)
755            .include_file("_inc.rs");
756        assert_eq!(cfg.files.len(), 2);
757        assert_eq!(cfg.includes.len(), 1);
758        assert!(cfg.options.buffa.strict_utf8_mapping);
759        assert!(!cfg.options.buffa.generate_json);
760        assert!(!cfg.options.buffa.emit_register_fn);
761        assert!(cfg.options.gate_client_feature);
762        assert_eq!(cfg.options.client_feature_name, "grpc-client");
763        assert_eq!(cfg.options.encodable_impls, EncodableImpls::AllMessages);
764        assert_eq!(cfg.include_file.as_deref(), Some("_inc.rs"));
765    }
766
767    #[test]
768    fn client_feature_name_enables_gating() {
769        let cfg = Config::new().client_feature_name("grpc-client");
770        assert!(
771            cfg.options.gate_client_feature,
772            "client_feature_name alone must enable gating (mirrors plugin \
773             gate_client_feature=<name>)"
774        );
775        assert_eq!(cfg.options.client_feature_name, "grpc-client");
776    }
777
778    #[test]
779    fn config_default_options() {
780        let cfg = Config::new();
781        assert!(!cfg.options.buffa.strict_utf8_mapping);
782        assert!(cfg.options.buffa.generate_json);
783        assert!(cfg.options.buffa.emit_register_fn);
784        // `gate_client_feature` defaults off — build.rs consumers don't
785        // have to declare a `client` Cargo feature unless they opt in.
786        assert!(!cfg.options.gate_client_feature);
787        assert_eq!(cfg.options.client_feature_name, "client");
788        // `encodable_impls` defaults to Outputs — impls are emitted for
789        // RPC output types only unless the crate opts in as a shared
790        // proto crate.
791        assert_eq!(cfg.options.encodable_impls, EncodableImpls::Outputs);
792        assert!(cfg.emit_rerun_directives);
793        assert!(matches!(cfg.descriptor_source, DescriptorSource::Protoc));
794    }
795
796    /// End-to-end through `Config`: with `gate_client_feature(true)`,
797    /// the generated `__connect.rs` contains `#[cfg(feature = "client")]`
798    /// on the `EchoServiceClient` struct + impl. Without the opt-in, the
799    /// cfg attr is absent. Uses the same `echo.fds.bin` fixture as
800    /// [`compile_precompiled_descriptor_set`].
801    #[test]
802    fn compile_gate_client_feature_emits_cfg_attr() {
803        let fixture = format!("{}/tests/fixtures/echo.fds.bin", env!("CARGO_MANIFEST_DIR"));
804
805        // Opt-in: cfg attrs present on the client items.
806        let out_with = tempfile::tempdir().unwrap();
807        Config::new()
808            .descriptor_set(&fixture)
809            .files(&["echo.proto"])
810            .out_dir(out_with.path())
811            .gate_client_feature(true)
812            .emit_rerun_directives(false)
813            .compile()
814            .expect("compile with gate_client_feature=true");
815        let gated = std::fs::read_to_string(out_with.path().join("echo.__connect.rs"))
816            .expect("read gated __connect.rs");
817        let cfg_count = gated.matches("#[cfg(feature = \"client\")]").count();
818        assert_eq!(
819            cfg_count, 2,
820            "expected exactly 2 cfg attrs (struct + impl) with \
821             gate_client_feature=true; got {cfg_count}:\n{gated}"
822        );
823        // Sanity: the server-side trait + ext trait must not be gated.
824        for marker in ["pub trait EchoService", "pub trait EchoServiceExt"] {
825            let idx = gated
826                .find(marker)
827                .unwrap_or_else(|| panic!("expected `{marker}` in output:\n{gated}"));
828            let prefix = &gated[..idx];
829            assert!(
830                !prefix.trim_end().ends_with("#[cfg(feature = \"client\")]"),
831                "`{marker}` must not be gated:\n{gated}"
832            );
833        }
834
835        // Custom name: cfg attrs use the configured feature name, not the
836        // default `client`.
837        let out_custom = tempfile::tempdir().unwrap();
838        Config::new()
839            .descriptor_set(&fixture)
840            .files(&["echo.proto"])
841            .out_dir(out_custom.path())
842            .gate_client_feature(true)
843            .client_feature_name("grpc-client")
844            .emit_rerun_directives(false)
845            .compile()
846            .expect("compile with custom client feature name");
847        let custom = std::fs::read_to_string(out_custom.path().join("echo.__connect.rs"))
848            .expect("read custom __connect.rs");
849        let custom_count = custom.matches("#[cfg(feature = \"grpc-client\")]").count();
850        assert_eq!(
851            custom_count, 2,
852            "expected exactly 2 custom cfg attrs (struct + impl); got \
853             {custom_count}:\n{custom}"
854        );
855        assert!(
856            !custom.contains("#[cfg(feature = \"client\")]"),
857            "custom client feature name must replace the default gate:\n{custom}"
858        );
859
860        // Opt-out (default): no cfg attrs anywhere in the same file.
861        let out_without = tempfile::tempdir().unwrap();
862        Config::new()
863            .descriptor_set(&fixture)
864            .files(&["echo.proto"])
865            .out_dir(out_without.path())
866            .emit_rerun_directives(false)
867            .compile()
868            .expect("compile with default options");
869        let ungated = std::fs::read_to_string(out_without.path().join("echo.__connect.rs"))
870            .expect("read default __connect.rs");
871        assert!(
872            !ungated.contains("#[cfg(feature ="),
873            "default emission must not emit any cfg attr — external \
874             consumers should not need to declare a `client` Cargo \
875             feature unless they opt in. Got:\n{ungated}"
876        );
877    }
878
879    #[test]
880    fn config_emit_rerun_directives_toggle() {
881        let cfg = Config::new().emit_rerun_directives(false);
882        assert!(!cfg.emit_rerun_directives);
883    }
884
885    #[test]
886    fn config_buffa_config_wholesale() {
887        let mut buffa = CodeGenConfig::default();
888        buffa.generate_text = true;
889        let cfg = Config::new().buffa_config(buffa);
890        assert!(cfg.options.buffa.generate_text);
891    }
892
893    #[test]
894    fn config_descriptor_source_variants() {
895        assert!(matches!(
896            Config::new().use_buf().descriptor_source,
897            DescriptorSource::Buf
898        ));
899        assert!(matches!(
900            Config::new().descriptor_set("x.bin").descriptor_source,
901            DescriptorSource::Precompiled(_)
902        ));
903    }
904
905    #[test]
906    fn config_emit_descriptor_set_toggle() {
907        let cfg = Config::new().emit_descriptor_set("d.bin");
908        assert_eq!(cfg.emit_descriptor_set.as_deref(), Some("d.bin"));
909    }
910
911    /// `emit_descriptor_set` writes the descriptor set used for codegen to
912    /// `<out_dir>/<name>` as a wire-format `FileDescriptorSet` ready for gRPC
913    /// server reflection. A precompiled source passes the bytes through
914    /// unchanged, so the emitted file round-trips the input set.
915    #[test]
916    fn emit_descriptor_set_writes_reflection_bin() {
917        let fixture = format!("{}/tests/fixtures/echo.fds.bin", env!("CARGO_MANIFEST_DIR"));
918        let out = tempfile::tempdir().unwrap();
919
920        Config::new()
921            .descriptor_set(&fixture)
922            .files(&["echo.proto"])
923            .out_dir(out.path())
924            .emit_descriptor_set("echo_descriptor.bin")
925            .compile()
926            .unwrap();
927
928        let emitted = out.path().join("echo_descriptor.bin");
929        assert!(emitted.exists(), "expected {emitted:?} to be written");
930
931        let bytes = std::fs::read(&emitted).unwrap();
932        let fds = FileDescriptorSet::decode_from_slice(&bytes)
933            .expect("emitted descriptor set must decode");
934        let names: Vec<_> = fds.file.iter().filter_map(|f| f.name.as_deref()).collect();
935        assert_eq!(
936            names,
937            ["echo.proto"],
938            "emitted set should contain the compiled file by name"
939        );
940
941        // Precompiled source passes bytes through unchanged → exact round-trip.
942        let fixture_bytes = std::fs::read(&fixture).unwrap();
943        assert_eq!(
944            bytes, fixture_bytes,
945            "emitted bytes must equal the source set"
946        );
947    }
948
949    /// The emitted set carries the full transitive import closure, not just
950    /// the files selected for generation: `imports.fds.bin` was built with
951    /// `protoc --include_imports` from `uses_dep.proto` (which imports
952    /// `dep.proto`), and both must appear in the emitted bytes — that is
953    /// what makes the file servable via `grpc.reflection.v1.ServerReflection`.
954    #[test]
955    fn emit_descriptor_set_preserves_import_closure() {
956        let fixture = format!(
957            "{}/tests/fixtures/imports.fds.bin",
958            env!("CARGO_MANIFEST_DIR")
959        );
960        let out = tempfile::tempdir().unwrap();
961
962        Config::new()
963            .descriptor_set(&fixture)
964            .files(&["uses_dep.proto"])
965            .out_dir(out.path())
966            .emit_descriptor_set("fixture_descriptor.bin")
967            .compile()
968            .unwrap();
969
970        let bytes = std::fs::read(out.path().join("fixture_descriptor.bin")).unwrap();
971        let fds = FileDescriptorSet::decode_from_slice(&bytes)
972            .expect("emitted descriptor set must decode");
973        let names: Vec<_> = fds.file.iter().filter_map(|f| f.name.as_deref()).collect();
974        assert!(
975            names.contains(&"dep.proto") && names.contains(&"uses_dep.proto"),
976            "emitted set must include the imported dependency, got {names:?}"
977        );
978    }
979
980    /// `emit_descriptor_set` promises `<out_dir>/<name>`; a name with path
981    /// separators (or an absolute path) would escape it via `Path::join`,
982    /// so it is rejected.
983    #[test]
984    fn emit_descriptor_set_rejects_path_separators() {
985        let fixture = format!("{}/tests/fixtures/echo.fds.bin", env!("CARGO_MANIFEST_DIR"));
986        for name in ["sub/d.bin", "../d.bin", "/tmp/d.bin"] {
987            let out = tempfile::tempdir().unwrap();
988            let err = Config::new()
989                .descriptor_set(&fixture)
990                .files(&["echo.proto"])
991                .out_dir(out.path())
992                .emit_descriptor_set(name)
993                .compile()
994                .unwrap_err();
995            assert!(
996                err.to_string().contains("bare file name"),
997                "expected bare-file-name error for {name:?}, got: {err}"
998            );
999        }
1000    }
1001
1002    /// End-to-end: precompiled descriptor set → generated Rust in a tempdir.
1003    /// Verifies the file layout and that the service binding imports use
1004    /// `::connectrpc::` (absolute path).
1005    #[test]
1006    fn compile_precompiled_descriptor_set() {
1007        let fixture = format!("{}/tests/fixtures/echo.fds.bin", env!("CARGO_MANIFEST_DIR"));
1008        let out = tempfile::tempdir().unwrap();
1009
1010        Config::new()
1011            .descriptor_set(&fixture)
1012            .files(&["echo.proto"])
1013            .out_dir(out.path())
1014            .include_file("_inc.rs")
1015            .compile()
1016            .unwrap();
1017
1018        // Per-file output: buffa message types in `echo.rs`, connect-rust
1019        // service code in the `echo.__connect.rs` companion file.
1020        let echo_rs = out.path().join("echo.rs");
1021        assert!(echo_rs.exists(), "expected {echo_rs:?} to exist");
1022        let msg_content = std::fs::read_to_string(&echo_rs).unwrap();
1023        assert!(msg_content.contains("pub struct EchoRequest"));
1024        assert!(msg_content.contains("pub struct EchoResponse"));
1025
1026        let connect_rs = out.path().join("echo.__connect.rs");
1027        assert!(connect_rs.exists(), "expected {connect_rs:?} to exist");
1028        let svc_content = std::fs::read_to_string(&connect_rs).unwrap();
1029        assert!(svc_content.contains("pub trait EchoService"));
1030        assert!(svc_content.contains("pub struct EchoServiceClient"));
1031        // Fully qualified paths (the module-collision fix): no top-level `use`
1032        // statements, all references are inline absolute paths like
1033        // `::connectrpc::Context`, `::std::sync::Arc`, etc.
1034        assert!(
1035            svc_content.contains("::connectrpc::"),
1036            "service code should use ::connectrpc:: fully qualified paths"
1037        );
1038        assert!(
1039            !svc_content.contains("\nuse "),
1040            "service code should not emit top-level use statements"
1041        );
1042
1043        // Include file nests under test.echo.v1 and wires only the
1044        // per-package stitcher (the stitcher itself include!s the
1045        // per-proto content files). Because out_dir() was set explicitly
1046        // (not defaulted from $OUT_DIR), includes are sibling-relative.
1047        let inc = std::fs::read_to_string(out.path().join("_inc.rs")).unwrap();
1048        assert!(inc.contains("pub mod test {"));
1049        assert!(inc.contains("pub mod echo {"));
1050        assert!(inc.contains("pub mod v1 {"));
1051        assert!(inc.contains(r#"include!("test.echo.v1.mod.rs");"#));
1052        // Stitcher pulls in buffa's content files plus the connect-rust
1053        // companion (wired via `apply_companions`).
1054        let stitcher = std::fs::read_to_string(out.path().join("test.echo.v1.mod.rs")).unwrap();
1055        assert!(stitcher.contains(r#"include!("echo.rs");"#));
1056        assert!(
1057            stitcher.contains(r#"include!("echo.__connect.rs");"#),
1058            "stitcher should include the connect companion file (requires apply_companions, buffa >= 0.5)"
1059        );
1060        assert!(stitcher.contains("pub mod __buffa"));
1061    }
1062
1063    #[test]
1064    fn compile_file_per_package_collapses_to_single_file() {
1065        let fixture = format!("{}/tests/fixtures/echo.fds.bin", env!("CARGO_MANIFEST_DIR"));
1066        let out = tempfile::tempdir().unwrap();
1067
1068        Config::new()
1069            .descriptor_set(&fixture)
1070            .files(&["echo.proto"])
1071            .out_dir(out.path())
1072            .include_file("_inc.rs")
1073            .file_per_package(true)
1074            .compile()
1075            .unwrap();
1076
1077        // No per-proto split, no companion siblings — everything lands in
1078        // the single per-package `<dotted.pkg>.rs` PackageMod.
1079        for stale in [
1080            "echo.rs",
1081            "echo.__connect.rs",
1082            "echo.__view.rs",
1083            "test.echo.v1.mod.rs",
1084        ] {
1085            assert!(
1086                !out.path().join(stale).exists(),
1087                "file_per_package must not emit {stale}"
1088            );
1089        }
1090        let pkg_rs = out.path().join("test.echo.v1.rs");
1091        assert!(pkg_rs.exists(), "expected {pkg_rs:?}");
1092        let content = std::fs::read_to_string(&pkg_rs).unwrap();
1093        assert!(
1094            content.contains("pub struct EchoRequest"),
1095            "missing message types"
1096        );
1097        assert!(
1098            content.contains("pub trait EchoService"),
1099            "missing service trait"
1100        );
1101        assert!(
1102            content.contains("pub struct EchoServiceClient"),
1103            "missing service client"
1104        );
1105        assert!(
1106            !content.contains("__connect.rs"),
1107            "single-file output must not include! a sibling: {content}"
1108        );
1109
1110        // Include file wires the per-package PackageMod as before — the
1111        // `<dotted.pkg>.rs` filename replaces `<pkg>.mod.rs` and the
1112        // nested-mod wrapping (which `<dotted.pkg>.rs` doesn't carry
1113        // itself) is still synthesised here.
1114        let inc = std::fs::read_to_string(out.path().join("_inc.rs")).unwrap();
1115        assert!(inc.contains(r#"include!("test.echo.v1.rs");"#));
1116        assert_eq!(
1117            inc.matches("include!").count(),
1118            1,
1119            "include file must wire exactly one PackageMod: {inc}"
1120        );
1121        for m in ["pub mod test {", "pub mod echo {", "pub mod v1 {"] {
1122            assert!(
1123                inc.contains(m),
1124                "include file missing nested mod {m:?}: {inc}"
1125            );
1126        }
1127    }
1128
1129    #[test]
1130    fn compile_rejects_unknown_file_names() {
1131        let fixture = format!("{}/tests/fixtures/echo.fds.bin", env!("CARGO_MANIFEST_DIR"));
1132        let out = tempfile::tempdir().unwrap();
1133
1134        let err = Config::new()
1135            .descriptor_set(&fixture)
1136            .files(&["nonexistent.proto"])
1137            .out_dir(out.path())
1138            .compile()
1139            .unwrap_err();
1140
1141        // buffa-codegen rejects unknown file_to_generate entries; verify that
1142        // error surfaces through compile() with the offending name.
1143        let msg = err.to_string();
1144        assert!(
1145            msg.contains("nonexistent.proto"),
1146            "error should name the missing file: {msg}"
1147        );
1148    }
1149
1150    /// descriptor_set() with nested proto paths must preserve directory
1151    /// components — users provide proto-relative names like
1152    /// "my/pkg/ping.proto" which match what's inside the FDS. Stripping
1153    /// to file_name() would fail to find the descriptor.
1154    #[test]
1155    fn compile_precompiled_preserves_nested_paths() {
1156        let fixture = format!(
1157            "{}/tests/fixtures/nested.fds.bin",
1158            env!("CARGO_MANIFEST_DIR")
1159        );
1160        let out = tempfile::tempdir().unwrap();
1161
1162        Config::new()
1163            .descriptor_set(&fixture)
1164            // nested path with directory components — before the fix,
1165            // this was stripped to "ping.proto" and failed to match
1166            .files(&["my/pkg/ping.proto"])
1167            .out_dir(out.path())
1168            .include_file("_inc.rs")
1169            .compile()
1170            .unwrap();
1171
1172        // Output filenames derived from proto path with dots: buffa
1173        // message types in `<stem>.rs`, connect-rust service code in the
1174        // `<stem>.__connect.rs` companion file.
1175        let msg_rs = out.path().join("my.pkg.ping.rs");
1176        assert!(msg_rs.exists(), "expected {msg_rs:?}");
1177        assert!(
1178            std::fs::read_to_string(&msg_rs)
1179                .unwrap()
1180                .contains("pub struct PingRequest")
1181        );
1182        let svc_rs = out.path().join("my.pkg.ping.__connect.rs");
1183        assert!(svc_rs.exists(), "expected {svc_rs:?}");
1184        assert!(
1185            std::fs::read_to_string(&svc_rs)
1186                .unwrap()
1187                .contains("pub trait PingService")
1188        );
1189
1190        // The dotted-stem stitcher must wire in the companion file by name;
1191        // this exercises `apply_companions` filename escaping for stems that
1192        // already contain `.` separators.
1193        let stitcher = std::fs::read_to_string(out.path().join("my.pkg.v1.mod.rs")).unwrap();
1194        assert!(
1195            stitcher.contains(r#"include!("my.pkg.ping.__connect.rs");"#),
1196            "stitcher should include the connect companion file (requires apply_companions, buffa >= 0.5)"
1197        );
1198
1199        // Include file nests under my.pkg.v1.
1200        let inc = std::fs::read_to_string(out.path().join("_inc.rs")).unwrap();
1201        assert!(inc.contains("pub mod my {"));
1202        assert!(inc.contains("pub mod pkg {"));
1203        assert!(inc.contains("pub mod v1 {"));
1204    }
1205
1206    #[test]
1207    fn strip_include_prefix_longest_first() {
1208        // With overlapping includes, the longest must win.
1209        let includes = vec![PathBuf::from("proto/vendor/"), PathBuf::from("proto/")];
1210        // Caller contract: sorted longest-first.
1211        let mut sorted = includes.clone();
1212        sorted.sort_by_key(|p| std::cmp::Reverse(p.as_os_str().len()));
1213        assert_eq!(sorted[0], PathBuf::from("proto/vendor/"));
1214
1215        let f = PathBuf::from("proto/vendor/thing.proto");
1216        assert_eq!(strip_include_prefix(&f, &sorted), "thing.proto");
1217
1218        let f = PathBuf::from("proto/my/svc.proto");
1219        assert_eq!(strip_include_prefix(&f, &sorted), "my/svc.proto");
1220    }
1221
1222    #[test]
1223    fn strip_include_prefix_fallback_to_filename() {
1224        let f = PathBuf::from("unrelated/path/svc.proto");
1225        let includes = vec![PathBuf::from("proto/")];
1226        assert_eq!(strip_include_prefix(&f, &includes), "svc.proto");
1227    }
1228
1229    #[test]
1230    fn proto_relative_names_verbatim() {
1231        let files = vec![
1232            PathBuf::from("my/pkg/svc.proto"),
1233            PathBuf::from("top.proto"),
1234        ];
1235        assert_eq!(
1236            proto_relative_names(&files),
1237            vec!["my/pkg/svc.proto".to_string(), "top.proto".to_string()]
1238        );
1239    }
1240
1241    #[test]
1242    fn write_if_changed_creates_new_file() {
1243        let dir = tempfile::tempdir().unwrap();
1244        let path = dir.path().join("new.rs");
1245        write_if_changed(&path, b"hello").unwrap();
1246        assert_eq!(std::fs::read(&path).unwrap(), b"hello");
1247    }
1248
1249    #[test]
1250    fn write_if_changed_skips_identical_content() {
1251        let dir = tempfile::tempdir().unwrap();
1252        let path = dir.path().join("same.rs");
1253        std::fs::write(&path, b"content").unwrap();
1254        let mtime_before = std::fs::metadata(&path).unwrap().modified().unwrap();
1255
1256        // Sleep briefly so a write would produce a distinguishable mtime.
1257        std::thread::sleep(std::time::Duration::from_millis(50));
1258
1259        write_if_changed(&path, b"content").unwrap();
1260        let mtime_after = std::fs::metadata(&path).unwrap().modified().unwrap();
1261        assert_eq!(mtime_before, mtime_after);
1262    }
1263
1264    #[test]
1265    fn write_if_changed_overwrites_different_content() {
1266        let dir = tempfile::tempdir().unwrap();
1267        let path = dir.path().join("changed.rs");
1268        std::fs::write(&path, b"old").unwrap();
1269
1270        write_if_changed(&path, b"new").unwrap();
1271        assert_eq!(std::fs::read(&path).unwrap(), b"new");
1272    }
1273}