Skip to main content

connectrpc_codegen/
codegen.rs

1//! Code generation logic for ConnectRPC Rust bindings.
2//!
3//! This module generates:
4//! - Buffa message types (via buffa-codegen)
5//! - ConnectRPC service traits and clients
6//!
7//! Code generation uses the `quote` crate for producing Rust code from
8//! TokenStreams, which provides better syntax highlighting, type safety,
9//! and maintainability compared to string-based generation.
10
11use std::collections::HashMap;
12
13use anyhow::Result;
14use heck::ToSnakeCase;
15use heck::ToUpperCamelCase;
16use proc_macro2::{Ident, Span, TokenStream};
17use quote::format_ident;
18use quote::quote;
19
20use buffa_codegen::generated::descriptor::DescriptorProto;
21use buffa_codegen::generated::descriptor::Edition;
22use buffa_codegen::generated::descriptor::FileDescriptorProto;
23use buffa_codegen::generated::descriptor::MethodDescriptorProto;
24use buffa_codegen::generated::descriptor::ServiceDescriptorProto;
25use buffa_codegen::generated::descriptor::SourceCodeInfo;
26use buffa_codegen::generated::descriptor::method_options::IdempotencyLevel;
27use buffa_codegen::idents::make_field_ident;
28use buffa_codegen::idents::rust_path_to_tokens;
29
30pub use buffa_codegen::generated::descriptor;
31pub use buffa_codegen::{CodeGenConfig, GeneratedFile, GeneratedFileKind};
32
33use crate::plugin::CodeGeneratorRequest;
34use crate::plugin::CodeGeneratorResponse;
35use crate::plugin::CodeGeneratorResponseFile;
36
37/// Options for ConnectRPC code generation.
38///
39/// These control both the underlying buffa message generation and the
40/// ConnectRPC service binding generation.
41///
42/// Construct via `Options::default()` then set fields on `buffa` directly
43/// (the struct is `#[non_exhaustive]`, so struct-update syntax is
44/// unavailable from outside this crate).
45#[derive(Debug, Clone)]
46#[non_exhaustive]
47pub struct Options {
48    /// The underlying buffa-codegen configuration. Set any
49    /// [`CodeGenConfig`] field directly here; connectrpc passes it through
50    /// verbatim except for [`CodeGenConfig::generate_views`], which is
51    /// forced to `true` (service stubs require view types).
52    ///
53    /// [`Options::default()`] starts from buffa's defaults but enables
54    /// `generate_json` (the Connect protocol's JSON codec needs it; buffa's
55    /// own default is `false`).
56    ///
57    /// `buffa.extern_paths` is used by [`generate_services`] to bake
58    /// absolute paths into service stubs (set a `(".", "crate::proto")`
59    /// catch-all so every type resolves); it is ignored by
60    /// [`generate_files`] (the unified `super::`-relative path).
61    ///
62    /// Every `extern_path` target must be buffa-generated code from
63    /// buffa ≥ 0.9.0 with views enabled (and, if the crate feature-gates
64    /// its generated impls, with that feature turned on): the service
65    /// stubs rely on the `buffa::HasMessageView` impls and `FooOwnedView`
66    /// wrappers emitted alongside each message, the same way they rely on
67    /// the JSON/`Serialize` impls. `buffa-types` 0.9+ satisfies this for
68    /// the well-known types. A crate generated without them fails to
69    /// compile against the stubs (missing `HasMessageView` impl).
70    pub buffa: CodeGenConfig,
71
72    /// When `true`, prefix every emitted `FooClient<T>` struct and its
73    /// `impl` block with `#[cfg(feature = "...")]`. Opt in when
74    /// the consuming crate wants to give server-only deployments a way
75    /// to drop the client transport stack from their dependency graph.
76    pub gate_client_feature: bool,
77
78    /// Cargo feature name used when [`Options::gate_client_feature`] is
79    /// enabled (default: `"client"`).
80    pub client_feature_name: String,
81
82    /// Which messages get generated `::connectrpc::Encodable` view impl
83    /// pairs (default: [`EncodableImpls::Outputs`], plugin opt
84    /// `encodable_impls=<all_messages|outputs>`).
85    ///
86    /// [`EncodableImpls::AllMessages`] emits the pair for **every message
87    /// defined in each targeted proto** and emits companion files even
88    /// for protos that declare no services. Use it in the generation run
89    /// of a crate that owns message types consumed by *other* crates'
90    /// services (a shared proto crate in a multi-crate split). Rust's
91    /// orphan rules require the `impl Encodable<M> for MView<'_>` blocks
92    /// to live in the crate that defines the view type, so a downstream
93    /// service crate cannot emit them itself — its stubs skip foreign
94    /// (`::`-rooted `extern_path`) types and handlers fall back to owned
95    /// returns or `PreEncoded::from_view`. With the impls in the owning
96    /// crate, those handlers can return views of the shared types
97    /// directly (proto codec only — like every view body, they answer
98    /// JSON-codec requests with `Unimplemented`).
99    ///
100    /// The emitting crate must depend on `connectrpc`, and the companion
101    /// files emitted for service-less packages must be mounted into the
102    /// module tree like any other plugin output — an unmounted companion
103    /// surfaces as a missing `Encodable` impl in the *consuming* crate.
104    /// Messages mapped away via
105    /// [`extern_paths`](CodeGenConfig::extern_paths) are skipped, but
106    /// only `::`-rooted targets are recognized as foreign — a
107    /// `crate::`-rooted re-export of another crate's types would still
108    /// get (orphan) impls. Synthetic map-entry messages never get impls.
109    ///
110    /// Enabling this in a service crate's own run is harmless (impls
111    /// dedup within one run), but do not enable it in two generation runs
112    /// that feed one crate and cover the same protos — each run emits its
113    /// own copy of the impls (E0119).
114    pub encodable_impls: EncodableImpls,
115}
116
117/// Which messages get generated `::connectrpc::Encodable` view impl
118/// pairs. See [`Options::encodable_impls`].
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
120#[non_exhaustive]
121pub enum EncodableImpls {
122    /// Emit the impl pair only for the RPC output types of the targeted
123    /// protos' services (the default).
124    #[default]
125    Outputs,
126    /// Emit the impl pair for every message defined in each targeted
127    /// proto, including protos that declare no services.
128    AllMessages,
129}
130
131impl Default for Options {
132    fn default() -> Self {
133        let mut buffa = CodeGenConfig::default();
134        buffa.generate_json = true;
135        Self {
136            buffa,
137            gate_client_feature: false,
138            client_feature_name: "client".into(),
139            encodable_impls: EncodableImpls::default(),
140        }
141    }
142}
143
144impl Options {
145    /// Clone the embedded buffa config and apply connectrpc's invariants
146    /// (`generate_views = true` — service stubs reference view types).
147    fn to_buffa_config(&self) -> CodeGenConfig {
148        let mut config = self.buffa.clone();
149        config.generate_views = true;
150        config
151    }
152
153    fn client_feature_name(&self) -> Result<&str> {
154        let name = self.client_feature_name.trim();
155        if name.is_empty() {
156            if self.gate_client_feature {
157                anyhow::bail!("client feature name must not be empty");
158            }
159            return Ok("client");
160        }
161        if !buffa_codegen::FeatureGateNames::is_valid_name(name) {
162            anyhow::bail!(
163                "client feature name {name:?} is not a valid Cargo feature name \
164                 (must start with an alphanumeric or `_` and contain only \
165                 alphanumerics, `_`, `-`, `+`, `.`)"
166            );
167        }
168        Ok(name)
169    }
170}
171
172/// Emit one [`GeneratedFile`] per proto file in `file_to_generate` that
173/// declares at least one `service`. Files with no services produce no
174/// output, unless [`Options::encodable_impls`] is [`EncodableImpls::AllMessages`] — then
175/// a file whose messages yield at least one `Encodable` impl pair gets a
176/// companion file too.
177fn emit_service_files(
178    proto_file: &[FileDescriptorProto],
179    file_to_generate: &[String],
180    resolver: &TypeResolver<'_>,
181    options: &Options,
182    client_feature_name: &str,
183) -> Result<Vec<GeneratedFile>> {
184    let mut out = Vec::new();
185    // Dedup state shared across the whole batch, not per file:
186    // - output-type Encodable impls (else two files sharing an output
187    //   type collide with E0119);
188    // - OwnedFooView aliases keyed on (package, fqn) (else two files in
189    //   the same package collide with E0428);
190    // - colliding-alias detection (issue #75) needs full-batch visibility
191    //   because the stitcher mounts sibling files into one module.
192    let mut batch = BatchState {
193        colliding_aliases: collect_alias_collisions(proto_file, file_to_generate),
194        gate_client_feature: options.gate_client_feature,
195        client_feature_name: client_feature_name.to_string(),
196        all_message_encodable_impls: options.encodable_impls == EncodableImpls::AllMessages,
197        ..BatchState::default()
198    };
199    for file_name in file_to_generate {
200        let file_desc = proto_file
201            .iter()
202            .find(|f| f.name.as_deref() == Some(file_name.as_str()));
203
204        if let Some(file) = file_desc
205            && (!file.service.is_empty()
206                || (batch.all_message_encodable_impls && !file.message_type.is_empty()))
207        {
208            let service_tokens = generate_connect_services(file, resolver, &mut batch)?;
209            if service_tokens.is_empty() {
210                // `all_messages` mode, but every message in this proto was
211                // either already emitted from another file (dedup) or
212                // extern-mapped to a foreign crate: nothing to write. A
213                // service-declaring proto always yields tokens (the trait
214                // alone guarantees it) — assert that invariant so a future
215                // regression can't silently drop a whole companion here.
216                debug_assert!(
217                    file.service.is_empty(),
218                    "service-declaring proto {file_name} produced no service tokens"
219                );
220                continue;
221            }
222            let service_code = format_token_stream(&service_tokens)?;
223            // Companion files are connect-rust's contribution alongside
224            // buffa's per-proto outputs. The `.__connect.rs` suffix avoids
225            // colliding with any of buffa's own filenames in the unified
226            // path (`<stem>.rs`, `<stem>.__view.rs`, ...) per the
227            // `apply_companions` contract; in the split path the plugin
228            // writes to its own output directory so the suffix is just a
229            // visible marker of the file's origin.
230            out.push(GeneratedFile {
231                name: format!(
232                    "{}.__connect.rs",
233                    buffa_codegen::proto_path_to_stem(file_name)
234                ),
235                package: file.package.clone().unwrap_or_default(),
236                kind: GeneratedFileKind::Companion,
237                content: service_code,
238            });
239        }
240    }
241    Ok(out)
242}
243
244/// Generate ConnectRPC service bindings + buffa message types from proto
245/// descriptors.
246///
247/// Returns buffa's per-proto [`GeneratedFile`]s (Owned, View, Oneof,
248/// ViewOneof, Ext, plus one PackageMod stitcher per package), with one
249/// [`GeneratedFileKind::Companion`] file per service-declaring proto
250/// (`<stem>.__connect.rs`) wired into the matching package stitcher via
251/// [`buffa_codegen::apply_companions`]. Under
252/// [`EncodableImpls::AllMessages`], protos without services also get a
253/// companion (Encodable impls only) when at least one of their messages
254/// yields an impl pair. Callers write every file to disk
255/// and wire only the [`GeneratedFileKind::PackageMod`] entries into their
256/// module tree (the stitchers `include!` the rest).
257///
258/// Under [`CodeGenConfig::file_per_package`] no `Companion` files are
259/// emitted: the service stubs are inlined directly into buffa's single
260/// `<dotted.pkg>.rs` `PackageMod` per package, mirroring how buffa
261/// inlines its own ancillary content under that mode.
262///
263/// This is the **unified** path: service stubs reference message types via
264/// `super::`-relative paths, so both must live in the same module tree.
265/// [`CodeGenConfig::extern_paths`] is ignored.
266///
267/// # Errors
268///
269/// Returns an error if buffa-codegen fails (e.g. unsupported proto
270/// feature), if a method input/output type is absent from `proto_file`
271/// (an import missing from the descriptor set), or if the generated
272/// service binding Rust does not parse under `syn` (indicates a bug in
273/// this crate).
274pub fn generate_files(
275    proto_file: &[FileDescriptorProto],
276    file_to_generate: &[String],
277    options: &Options,
278) -> Result<Vec<GeneratedFile>> {
279    let config = options.to_buffa_config();
280
281    let mut files = buffa_codegen::generate(proto_file, file_to_generate, &config)
282        .map_err(|e| anyhow::anyhow!("buffa-codegen failed: {e}"))?;
283
284    let resolver = TypeResolver::new(proto_file, file_to_generate, &config, false);
285    let client_feature_name = options.client_feature_name()?;
286    let service_files = emit_service_files(
287        proto_file,
288        file_to_generate,
289        &resolver,
290        options,
291        client_feature_name,
292    )?;
293
294    if config.file_per_package {
295        // Under `file_per_package` buffa emits one `<dotted.pkg>.rs`
296        // (kind `PackageMod`) per package, inlining what the per-file
297        // stitcher would otherwise `include!`. Inline the service stubs
298        // into it directly so the output stays single-file-per-package —
299        // a sibling `<stem>.__connect.rs` would defeat the layout's
300        // purpose (BSR/`tonic`-style `lib.rs` synthesis from
301        // `<dotted.package>.rs` filenames).
302        inline_companions_into_package_mods(&mut files, service_files);
303    } else {
304        // Wire each `<stem>.__connect.rs` into the matching per-package
305        // stitcher and append the companion files to the output set in one
306        // pass. Every companion's package has a matching PackageMod here
307        // because buffa unconditionally emits one for every package
308        // containing a `file_to_generate` proto, so no companion is ever
309        // orphaned.
310        buffa_codegen::apply_companions(&mut files, service_files);
311
312        // The orphaning safety above is a cross-crate invariant on buffa's
313        // output shape; if a future buffa release stops emitting a
314        // PackageMod for an empty package, `apply_companions` would
315        // silently append the companion without any stitcher wiring it in.
316        // Surface that early in debug builds rather than letting the
317        // trait/client vanish at use-site.
318        debug_assert!(
319            files.iter().all(|f| {
320                f.kind != GeneratedFileKind::Companion
321                    || files.iter().any(|g| {
322                        g.kind == GeneratedFileKind::PackageMod
323                            && g.content.contains(&format!("include!(\"{}\")", f.name))
324                    })
325            }),
326            "a companion service file was not wired into any package stitcher"
327        );
328    }
329
330    Ok(files)
331}
332
333/// Append each companion's content directly to the matching `PackageMod`,
334/// dropping the companion entries instead of `apply_companions`-ing them
335/// as separate `include!`d siblings.
336///
337/// Used by [`generate_files`] under [`CodeGenConfig::file_per_package`],
338/// where the `PackageMod` is the *only* per-package output file and a
339/// sibling `<stem>.__connect.rs` would break the single-file convention
340/// that BSR/`tonic`-style `lib.rs` synthesis depends on.
341///
342/// Companions whose package has no `PackageMod` are dropped — that does
343/// not arise in [`generate_files`] (buffa unconditionally emits one per
344/// `file_to_generate` package). Note this differs from `apply_companions`,
345/// which appends-without-wiring (the dangling `.__connect.rs` lands on
346/// disk as a debugging breadcrumb): here the orphan vanishes entirely.
347/// Both paths yield a missing-symbol error at the consumer, but the
348/// `debug_assert!` in [`generate_files`]'s default branch covers the
349/// dangerous half (silent unwired siblings); this branch has no sibling
350/// to leave dangling, so a vanished trait is the only signature.
351fn inline_companions_into_package_mods(
352    // Slice not Vec: this path mutates PackageMod content in place and
353    // never appends — companions are consumed by the loop, not retained.
354    files: &mut [GeneratedFile],
355    companions: Vec<GeneratedFile>,
356) {
357    // Symmetric to the `debug_assert!` in `generate_files`'s default branch:
358    // this branch leaves nothing on disk for an orphan, so the assertion is
359    // the *only* signal if buffa's PackageMod-emission contract changes.
360    debug_assert!(
361        companions.iter().all(|c| files
362            .iter()
363            .any(|f| f.kind == GeneratedFileKind::PackageMod && f.package == c.package)),
364        "a companion service file's package has no PackageMod to inline into"
365    );
366    for comp in companions {
367        if let Some(pkg_mod) = files
368            .iter_mut()
369            .find(|f| f.kind == GeneratedFileKind::PackageMod && f.package == comp.package)
370        {
371            pkg_mod.content.push('\n');
372            pkg_mod.content.push_str(&comp.content);
373        }
374    }
375}
376
377/// Generate **only** ConnectRPC service bindings from proto descriptors.
378///
379/// Returns one `<stem>.__connect.rs` `GeneratedFile` per proto file in
380/// `file_to_generate` that declares at least one `service` — plus, under
381/// [`EncodableImpls::AllMessages`], per proto whose messages yield at
382/// least one `Encodable` impl pair — and one `<pkg>.mod.rs` stitcher per
383/// package with output. No message types.
384///
385/// Service files carry [`GeneratedFileKind::Companion`] for symmetry with
386/// [`generate_files`], even though this path never calls
387/// `apply_companions`: the split-path stitcher emitted here `include!`s
388/// them directly. Build integrations filtering on kind should treat
389/// `Companion` as "connect-rust service stub" in both modes.
390///
391/// Under [`CodeGenConfig::file_per_package`] the per-proto split is
392/// collapsed: the output is exactly one `<dotted.pkg>.rs` (kind
393/// [`GeneratedFileKind::PackageMod`]) per package with all service stubs
394/// inlined, and no `<pkg>.mod.rs` stitcher. This matches the file layout
395/// `protoc-gen-buffa` produces under the same option and the convention
396/// that BSR cargo SDK generation and `tonic`-style build integrations
397/// expect (one `<dotted.package>.rs` per package, module tree synthesised
398/// from filenames). Route this output to its own directory — it shares
399/// `protoc-gen-buffa`'s filename per package and would silently overwrite
400/// in a shared one.
401///
402/// This is the **split** path: service stubs reference message types via
403/// absolute Rust paths derived from [`CodeGenConfig::extern_paths`]. Callers must
404/// set at least a `.` catch-all entry (e.g. `(".", "crate::proto")`) so
405/// every type resolves; the auto-injected WKT mapping still takes priority
406/// via longest-prefix-match. The generated code compiles standalone as long
407/// as the extern paths point at a buffa-generated module tree.
408///
409/// # Errors
410///
411/// Errors if any method input/output type is not covered by an extern_path
412/// mapping, or is absent from `proto_file` (missing import).
413pub fn generate_services(
414    proto_file: &[FileDescriptorProto],
415    file_to_generate: &[String],
416    options: &Options,
417) -> Result<Vec<GeneratedFile>> {
418    use std::collections::BTreeMap;
419
420    let config = options.to_buffa_config();
421    let resolver = TypeResolver::new(proto_file, file_to_generate, &config, true);
422    let client_feature_name = options.client_feature_name()?;
423    let mut files = emit_service_files(
424        proto_file,
425        file_to_generate,
426        &resolver,
427        options,
428        client_feature_name,
429    )?;
430
431    if config.file_per_package {
432        // Collapse the per-proto split into one `<dotted.pkg>.rs` per
433        // package (kind `PackageMod`) with all service stubs inlined.
434        // No stitcher — module tree wiring is the consumer's job (BSR
435        // `lib.rs` synthesis, hand-written `mod.rs`, ...).
436        let mut by_package: BTreeMap<String, String> = BTreeMap::new();
437        for f in files {
438            let entry = by_package.entry(f.package).or_insert_with(|| {
439                String::from("// @generated by connectrpc-codegen. DO NOT EDIT.\n")
440            });
441            entry.push('\n');
442            entry.push_str(&f.content);
443        }
444        return Ok(by_package
445            .into_iter()
446            .map(|(package, content)| GeneratedFile {
447                name: buffa_codegen::package_to_filename(&package),
448                package,
449                kind: GeneratedFileKind::PackageMod,
450                content,
451            })
452            .collect());
453    }
454
455    // Emit a per-package `<pkg>.mod.rs` stitcher for each package with at
456    // least one service-declaring proto, so `protoc-gen-buffa-packaging`
457    // can wire this output the same way it wires buffa's. The stitcher
458    // here is trivial — just `include!("<stem>.__connect.rs")` per file;
459    // there's no view/oneof ancillary tree for service stubs.
460    let mut by_package: BTreeMap<String, Vec<String>> = BTreeMap::new();
461    for f in &files {
462        by_package
463            .entry(f.package.clone())
464            .or_default()
465            .push(f.name.clone());
466    }
467    for (package, names) in by_package {
468        let mut content = String::from("// @generated by connectrpc-codegen. DO NOT EDIT.\n");
469        for n in &names {
470            // {:?} on the filename gives a quoted, escaped string literal.
471            content.push_str(&format!("include!({n:?});\n"));
472        }
473        files.push(GeneratedFile {
474            name: buffa_codegen::package_to_mod_filename(&package),
475            package,
476            kind: GeneratedFileKind::PackageMod,
477            content,
478        });
479    }
480
481    Ok(files)
482}
483
484/// Generate a `CodeGeneratorResponse` from a protoc `CodeGeneratorRequest`.
485///
486/// This is the entry point for the protoc plugin (`protoc-gen-connect-rust`).
487/// It parses the comma-separated `request.parameter` into [`Options`] and
488/// delegates to [`generate_services`] — service stubs only. Callers must
489/// run `protoc-gen-buffa` (or equivalent) separately for message types.
490///
491/// # Output
492///
493/// Per proto with at least one `service`: a `<stem>.__connect.rs` content
494/// file with the service stubs. Under `encodable_impls=all_messages`,
495/// also per proto whose messages yield at least one `Encodable` impl
496/// pair. Per package with at least one such proto:
497/// a `<pkg>.mod.rs` stitcher that `include!`s the content files. The
498/// stitcher filename intentionally matches `protoc-gen-buffa`'s, so run
499/// this plugin into a separate output directory and use
500/// `protoc-gen-buffa-packaging` to wire both trees, as shown in this
501/// repo's `buf.gen.yaml` examples.
502///
503/// Under `file_per_package` the per-proto split is collapsed: one
504/// `<dotted.pkg>.rs` per package with all service stubs inlined, no
505/// per-proto content files, and no stitcher. **Drop the
506/// `protoc-gen-buffa-packaging` invocations from your `buf.gen.yaml`
507/// under this layout** — there are no per-file content files or
508/// stitchers for it to wire, and leaving it in produces dead `mod.rs`
509/// output without an error. Either let your downstream build tool
510/// synthesise the module tree from `<dotted.package>.rs` filenames (BSR
511/// cargo SDKs do this automatically) or hand-write the `mod.rs`. See
512/// [`generate_services`].
513///
514/// A worked `file_per_package` `buf.gen.yaml`:
515///
516/// ```yaml
517/// version: v2
518/// plugins:
519///   - local: protoc-gen-buffa
520///     out: src/gen/buffa
521///     opt: [file_per_package]
522///   - local: protoc-gen-connect-rust
523///     out: src/gen/connect
524///     opt: [file_per_package, buffa_module=crate::gen::buffa]
525/// ```
526///
527/// You then mount each tree with a hand-written `mod.rs` (or let BSR's
528/// cargo SDK pipeline do it):
529///
530/// ```rust,ignore
531/// pub mod buffa { /* one `pub mod <pkg> { include!("<pkg>.rs"); }` per package */ }
532/// pub mod connect { /* same, pointing at src/gen/connect */ }
533/// ```
534///
535/// # Recognized options
536///
537/// - `buffa_module=<rust_path>` — where you mounted the buffa-generated
538///   module tree (e.g. `buffa_module=crate::proto`). Shorthand for
539///   `extern_path=.=<rust_path>`. This is the option most local users want.
540/// - `extern_path=<proto>=<rust>` — map a specific proto package prefix
541///   to a Rust module path. Repeatable; longest-prefix-match wins.
542///   `extern_path=.=<path>` is the catch-all (equivalent to `buffa_module`).
543///   At least one catch-all mapping is required so every type resolves.
544///   Every mapped path must point at buffa-generated code from
545///   buffa ≥ 0.9.0 with views enabled — the stubs use the
546///   `buffa::HasMessageView` impls and owned-view wrappers generated with
547///   each message (`buffa-types` 0.9+ qualifies for the well-known types).
548/// - `file_per_package` — emit one `<dotted.pkg>.rs` per proto package
549///   instead of the per-proto split + stitcher. Set `protoc-gen-buffa`'s
550///   own `file_per_package` option to the same value — the BSR/`tonic`
551///   `lib.rs` synthesis assumes both plugins use the same filename
552///   convention; mismatched settings produce a valid but asymmetric
553///   layout you would have to wire by hand. Keep using a dedicated
554///   output directory (the documented split-path setup already does
555///   this) — the filename matches `protoc-gen-buffa`'s and would
556///   silently overwrite in a shared one. See
557///   [`CodeGenConfig::file_per_package`] for the `strategy: directory`
558///   constraint.
559/// - `strict_utf8_mapping` — see [`CodeGenConfig::strict_utf8_mapping`].
560/// - `no_json` — disable `serde` derives on generated message types, for
561///   proto-only builds. Pair it with `connectrpc`'s `default-features = false`
562///   (the `json` cargo feature off) so the runtime drops its matching serde
563///   bounds. Ignored in this plugin (no message types emitted); accepted for
564///   compatibility with the unified path.
565/// - `no_register_fn` — suppress the per-file
566///   `register_types(&mut TypeRegistry)` aggregator. See
567///   [`CodeGenConfig::emit_register_fn`]. Ignored in this plugin (no message
568///   types emitted); accepted for compatibility with the unified path.
569/// - `gate_client_feature` — prefix every emitted `FooClient<T>`
570///   struct and its `impl` block with `#[cfg(feature = "client")]`.
571/// - `gate_client_feature=<name>` — same gate, but use `<name>` as the
572///   Cargo feature instead of `client`.
573/// - `encodable_impls=all_messages` — emit the `::connectrpc::Encodable`
574///   view impl pair for every message defined in each targeted proto, not
575///   only for RPC output types, and emit a companion file even for protos
576///   that declare no services. Use this in the generation run of a crate
577///   that owns message types consumed by *other* crates' services (a
578///   shared proto crate in a multi-crate split): Rust's orphan rules
579///   require these impls to live in the crate that defines the view
580///   types, so downstream service crates skip them and their handlers
581///   would otherwise have to return owned messages or
582///   `PreEncoded::from_view`. (View bodies serve the proto codec only —
583///   JSON-codec requests get `Unimplemented`, as for any view body.)
584///   The emitting crate must depend on `connectrpc`, and the companion
585///   files for service-less packages must be mounted like any other
586///   plugin output — an unmounted companion surfaces as a missing
587///   `Encodable` impl in the *consuming* crate. Messages mapped to a
588///   foreign crate via `extern_path` are still skipped.
589///   `encodable_impls=outputs` is the explicit default. See
590///   [`Options::encodable_impls`].
591/// - `element_memory_limit=<bytes|unlimited>` — raises the element-memory
592///   budget used to decode the request, for schemas too large for the
593///   default (see "Very large schemas" in the guide). Accepted and ignored
594///   here: it governs the decode that produced the `CodeGeneratorRequest`,
595///   so `buffa_codegen::decode_request` has already read and applied it by
596///   scanning the wire. A caller who decodes the request itself and then
597///   calls this function must apply the option on that decode; passing it
598///   here alone does nothing.
599///
600/// # Client-side cfg gate
601///
602/// When `gate_client_feature` is set, the consumer crate must declare
603/// the named Cargo feature (`client` by default). Without it, the generated
604/// `FooClient` items will be absent from the crate namespace.
605///
606/// Two consumer patterns:
607///
608/// 1. **Dep-forwarding** (`client = ["connectrpc/client"]`, with
609///    `connectrpc = { ..., features = ["server"] }` and no `"client"`
610///    in that dep's feature list): turns the gate into a real
611///    server-only escape hatch. Disabling the feature drops
612///    `connectrpc/client` (and its transport stack) from the
613///    dependency graph entirely. This is the intended use; see
614///    `connectrpc-health` for the minimal example.
615///
616/// 2. **Marker** (`client = []`, no forwarding): satisfies the gate
617///    without slimming the dependency graph. Use only when you want
618///    the cfg infrastructure in place but aren't ready to gate the
619///    dep yet.
620pub fn generate(request: &CodeGeneratorRequest) -> Result<CodeGeneratorResponse> {
621    let mut options = Options::default();
622
623    if let Some(ref param) = request.parameter {
624        for opt in param.split(',').map(str::trim).filter(|s| !s.is_empty()) {
625            if let Some(value) = opt.strip_prefix("buffa_module=") {
626                let rust = value.trim();
627                if rust.is_empty() {
628                    anyhow::bail!(
629                        "buffa_module requires a non-empty path, \
630                         e.g. buffa_module=crate::proto"
631                    );
632                }
633                options
634                    .buffa
635                    .extern_paths
636                    .push((".".into(), rust.to_string()));
637            } else if let Some(value) = opt.strip_prefix("extern_path=") {
638                // value is "<proto_path>=<rust_path>"
639                let (proto, rust) = value.split_once('=').ok_or_else(|| {
640                    anyhow::anyhow!(
641                        "invalid extern_path format {value:?}, expected \
642                         extern_path=.proto.pkg=::rust::path"
643                    )
644                })?;
645                let proto = proto.trim();
646                let rust = rust.trim();
647                if proto.is_empty() || rust.is_empty() {
648                    anyhow::bail!(
649                        "invalid extern_path format {value:?}, expected \
650                         extern_path=.proto.pkg=::rust::path (both sides non-empty)"
651                    );
652                }
653                let mut proto = proto.to_string();
654                if !proto.starts_with('.') {
655                    proto.insert(0, '.');
656                }
657                options.buffa.extern_paths.push((proto, rust.to_string()));
658            } else if let Some(value) = opt.strip_prefix("gate_client_feature=") {
659                let feature = value.trim();
660                if feature.is_empty() {
661                    anyhow::bail!("gate_client_feature requires a non-empty feature name");
662                }
663                options.gate_client_feature = true;
664                options.client_feature_name = feature.to_string();
665            } else if let Some(value) = opt.strip_prefix("encodable_impls=") {
666                match value.trim() {
667                    "all_messages" => options.encodable_impls = EncodableImpls::AllMessages,
668                    "outputs" => options.encodable_impls = EncodableImpls::Outputs,
669                    other => anyhow::bail!(
670                        "invalid encodable_impls value {other:?}, expected \
671                         `all_messages` or `outputs`"
672                    ),
673                }
674            } else if opt
675                .split_once('=')
676                .is_some_and(|(key, _)| key.trim() == buffa_codegen::ELEMENT_MEMORY_LIMIT_OPT)
677            {
678                // Consumed before this point, by the scan `decode_request` runs
679                // to size the decode that produced `request`. Reaching the
680                // unknown-option arm would reject it from the one caller who
681                // needs it: whoever's schema was too large to decode.
682            } else {
683                match opt {
684                    "file_per_package" => options.buffa.file_per_package = true,
685                    "strict_utf8_mapping" => options.buffa.strict_utf8_mapping = true,
686                    "no_json" => options.buffa.generate_json = false,
687                    "no_register_fn" => options.buffa.emit_register_fn = false,
688                    "gate_client_feature" => options.gate_client_feature = true,
689                    _ => {
690                        return Err(anyhow::anyhow!(
691                            "unknown plugin option: {opt:?}. Supported: \
692                             buffa_module=<rust_path>, extern_path=<proto>=<rust>, \
693                             encodable_impls=<all_messages|outputs>, \
694                             {mem}=<bytes|unlimited>, \
695                             file_per_package, strict_utf8_mapping, no_json, \
696                             no_register_fn, gate_client_feature, \
697                             gate_client_feature=<name>",
698                            mem = buffa_codegen::ELEMENT_MEMORY_LIMIT_OPT
699                        ));
700                    }
701                }
702            }
703        }
704    }
705
706    let generated = generate_services(&request.proto_file, &request.file_to_generate, &options)?;
707
708    let files: Vec<CodeGeneratorResponseFile> = generated
709        .into_iter()
710        .map(|g| CodeGeneratorResponseFile {
711            name: Some(g.name),
712            content: Some(g.content),
713            ..Default::default()
714        })
715        .collect();
716
717    Ok(CodeGeneratorResponse {
718        supported_features: Some(feature_flags()),
719        minimum_edition: Some(Edition::EDITION_2023 as i32),
720        maximum_edition: Some(Edition::EDITION_2024 as i32),
721        file: files,
722        ..Default::default()
723    })
724}
725
726/// Feature flags we support (bitmask). See
727/// `google.protobuf.compiler.CodeGeneratorResponse.Feature`.
728fn feature_flags() -> u64 {
729    const FEATURE_PROTO3_OPTIONAL: u64 = 1;
730    const FEATURE_SUPPORTS_EDITIONS: u64 = 2;
731    FEATURE_PROTO3_OPTIONAL | FEATURE_SUPPORTS_EDITIONS
732}
733
734/// Format a TokenStream into a Rust source string via prettyplease.
735fn format_token_stream(tokens: &TokenStream) -> Result<String> {
736    let file = syn::parse2::<syn::File>(tokens.clone())
737        .map_err(|e| anyhow::anyhow!("generated code failed to parse: {e}"))?;
738    Ok(prettyplease::unparse(&file))
739}
740
741/// Emit `#[doc = " line"]` attributes for each line of `text`.
742///
743/// prettyplease renders `#[doc = "X"]` as `///X` verbatim (no space inserted);
744/// to get `/// X` the string must already start with a space. This helper
745/// prefixes each line with a space so the unparsed output matches hand-written
746/// doc comment style.
747///
748/// Leaves blank lines as-is (→ `///`) so paragraph breaks render correctly.
749fn doc_attrs(text: &str) -> TokenStream {
750    let lines: Vec<String> = text
751        .lines()
752        .map(|l| {
753            if l.is_empty() {
754                String::new()
755            } else {
756                format!(" {l}")
757            }
758        })
759        .collect();
760    quote! { #(#[doc = #lines])* }
761}
762
763// ---------------------------------------------------------------------------
764// Type path resolution
765// ---------------------------------------------------------------------------
766
767/// Resolves fully-qualified protobuf type names to Rust type-path tokens
768/// relative to the current file's package module.
769///
770/// Wraps [`buffa_codegen::context::CodeGenContext`] via `for_generate()` so
771/// service method input/output types resolve to the same paths buffa-codegen
772/// emits for message fields — including cross-package (`super::foo::Bar`),
773/// WKT extern paths (`::buffa_types::google::protobuf::Empty`), and nested
774/// types (`outer::Inner`). Zero drift with buffa's own generation.
775struct TypeResolver<'a> {
776    ctx: buffa_codegen::context::CodeGenContext<'a>,
777    /// When true, every resolved path must be absolute (`::foo` or
778    /// `crate::foo`). Paths that would resolve to `super::`-relative or
779    /// bare-ident forms produce an error instead. Used by
780    /// [`generate_services`] to enforce that service stubs reference
781    /// message types via `extern_path` only.
782    require_extern: bool,
783}
784
785impl<'a> TypeResolver<'a> {
786    fn new(
787        proto_file: &'a [FileDescriptorProto],
788        file_to_generate: &[String],
789        config: &'a buffa_codegen::CodeGenConfig,
790        require_extern: bool,
791    ) -> Self {
792        Self {
793            ctx: buffa_codegen::context::CodeGenContext::for_generate(
794                proto_file,
795                file_to_generate,
796                config,
797            ),
798            require_extern,
799        }
800    }
801
802    /// Resolve a proto FQN (e.g. `.google.protobuf.Empty`) to a Rust type-path
803    /// string relative to `current_package`.
804    ///
805    /// Errors if the type is absent from the descriptor set, and — in
806    /// `require_extern` mode — if the resolved path is not absolute.
807    fn resolve_path(&self, proto_fqn: &str, current_package: &str) -> Result<String> {
808        match self.ctx.rust_type_relative(proto_fqn, current_package, 0) {
809            Some(path) => {
810                self.check_extern_coverage(proto_fqn, &path)?;
811                Ok(path)
812            }
813            None => Err(self.unresolved_type_error(proto_fqn)),
814        }
815    }
816
817    /// In `require_extern` mode, fail if `path_prefix` isn't an absolute or
818    /// crate-rooted path (i.e., the type wasn't covered by an extern_path
819    /// mapping). No-op otherwise.
820    fn check_extern_coverage(&self, proto_fqn: &str, path_prefix: &str) -> Result<()> {
821        if self.require_extern
822            && !path_prefix.starts_with("::")
823            && !path_prefix.starts_with("crate::")
824        {
825            anyhow::bail!(
826                "type {proto_fqn} is not covered by any extern_path mapping. \
827                 Add extern_path=.=<your_buffa_module> (e.g. \
828                 extern_path=.=crate::proto) to the plugin opts."
829            );
830        }
831        Ok(())
832    }
833
834    /// Error for a proto FQN absent from the descriptor set. Shared by the
835    /// type and view resolution paths so both report the same fix.
836    ///
837    /// The precompiled-set hint is for [`generate_files`] only: protoc
838    /// hands the plugin path a complete import closure, so a plugin user
839    /// has no descriptor set of their own to rebuild.
840    fn unresolved_type_error(&self, proto_fqn: &str) -> anyhow::Error {
841        let hint = if self.require_extern {
842            ""
843        } else {
844            " for a precompiled descriptor set, rebuild it with --include_imports"
845        };
846        anyhow::anyhow!(
847            "type {proto_fqn} not found in descriptor set (missing proto import?{hint})"
848        )
849    }
850
851    /// Resolve a proto FQN to Rust type-path tokens.
852    fn rust_type(&self, proto_fqn: &str, current_package: &str) -> Result<TokenStream> {
853        let path = self.resolve_path(proto_fqn, current_package)?;
854        Ok(rust_path_to_tokens(&path))
855    }
856
857    /// Resolve a proto FQN to its **view** Rust type-path tokens.
858    ///
859    /// Under buffa's `__buffa::` ancillary tree, view types live at
860    /// `<to-package>::__buffa::view::<within-package>View`, so this uses
861    /// `CodeGenContext::rust_type_relative_split` to find the package
862    /// boundary and inserts the sentinel path between the two halves.
863    fn rust_view_type(&self, proto_fqn: &str, current_package: &str) -> Result<TokenStream> {
864        use buffa_codegen::context::SENTINEL_MOD;
865        let (to_package, within) =
866            match self
867                .ctx
868                .rust_type_relative_split(proto_fqn, current_package, 0)
869            {
870                Some(s) => {
871                    self.check_extern_coverage(proto_fqn, &s.to_package)?;
872                    (s.to_package, s.within_package)
873                }
874                None => return Err(self.unresolved_type_error(proto_fqn)),
875            };
876        let prefix = if to_package.is_empty() {
877            format!("{SENTINEL_MOD}::view")
878        } else {
879            format!("{to_package}::{SENTINEL_MOD}::view")
880        };
881        Ok(rust_path_to_tokens(&format!("{prefix}::{within}View")))
882    }
883}
884
885/// Last segment of a proto FQN, e.g. `.google.protobuf.Empty` → `"Empty"`.
886fn bare_type_name(proto_fqn: &str) -> &str {
887    proto_fqn
888        .strip_prefix('.')
889        .unwrap_or(proto_fqn)
890        .rsplit('.')
891        .next()
892        .unwrap_or(proto_fqn)
893}
894
895// ---------------------------------------------------------------------------
896// ConnectRPC service code generation
897// ---------------------------------------------------------------------------
898
899/// Generate ConnectRPC service bindings for a file.
900/// Per-batch dedup state passed through the per-file emission loop.
901#[derive(Default)]
902struct BatchState {
903    /// Proto FQNs of output types whose `Encodable<M>` view impls have
904    /// already been emitted (global; impls are not module-scoped).
905    encodable_seen: std::collections::BTreeSet<String>,
906    /// `(package, proto FQN)` of input/output types whose
907    /// `Owned#{Msg}View` alias has already been emitted (per package
908    /// module; aliases are module-scoped).
909    alias_seen: std::collections::BTreeSet<(String, String)>,
910    /// `(package, alias_name)` pairs where two or more distinct FQNs would
911    /// produce the same `Owned<Msg>View` alias in the same target Rust
912    /// module — e.g. a service file that defines its own `MyMessage` and
913    /// also references an imported `.api.v1.foo.bar.MyMessage` (issue
914    /// [#75]). The alias is suppressed for every member of a colliding
915    /// set; trait method signatures inline the
916    /// `::buffa::view::OwnedView<…<'static>>` form for those types
917    /// instead. Aliases for non-colliding types (the common case,
918    /// including same-package and well-known types like
919    /// `.google.protobuf.Empty`) are unaffected.
920    ///
921    /// [#75]: https://github.com/anthropics/connect-rust/issues/75
922    colliding_aliases: std::collections::BTreeSet<(String, String)>,
923    /// Mirrors [`Options::gate_client_feature`]. When `true`, prefix
924    /// each emitted `FooClient<T>` struct + `impl` with
925    /// `#[cfg(feature = "...")]`. Threaded here so it propagates
926    /// through the per-file emission loop without changing every
927    /// helper's signature.
928    gate_client_feature: bool,
929    /// Mirrors [`Options::client_feature_name`].
930    client_feature_name: String,
931    /// Mirrors [`Options::encodable_impls`] == `AllMessages`. Threaded here so
932    /// it propagates through the per-file emission loop without changing
933    /// every helper's signature.
934    all_message_encodable_impls: bool,
935}
936
937impl BatchState {
938    fn client_feature_name(&self) -> &str {
939        if self.client_feature_name.is_empty() {
940            "client"
941        } else {
942            &self.client_feature_name
943        }
944    }
945}
946
947fn generate_connect_services(
948    file: &FileDescriptorProto,
949    resolver: &TypeResolver<'_>,
950    batch: &mut BatchState,
951) -> Result<TokenStream> {
952    let mut tokens = TokenStream::new();
953
954    // All types in generated code use fully qualified paths (e.g.
955    // `::std::sync::Arc`, `::connectrpc::Context`) so that multiple service
956    // files can be `include!`d into the same module without E0252 duplicate
957    // import errors.
958
959    // The view-family impls (`buffa::HasMessageView`) are emitted by buffa's
960    // own codegen alongside each message's view and owned-view wrapper, so
961    // nothing service-specific is needed here for `ServiceRequest` /
962    // `StreamMessage` to be usable.
963    tokens.extend(generate_owned_view_aliases(file, resolver, batch)?);
964    tokens.extend(generate_encodable_view_impls(file, resolver, batch)?);
965    if batch.all_message_encodable_impls {
966        tokens.extend(generate_all_message_encodable_impls(file, resolver, batch)?);
967    }
968
969    for service in &file.service {
970        tokens.extend(generate_service(file, service, resolver, batch)?);
971    }
972
973    Ok(tokens)
974}
975
976/// `Owned#{Msg}View` alias name for a proto FQN, e.g.
977/// `.example.v1.Record` → `OwnedRecordView`.
978fn owned_view_alias_ident(fqn: &str) -> Ident {
979    format_ident!("Owned{}View", bare_type_name(fqn).to_upper_camel_case())
980}
981
982/// True iff emitting `Owned<Msg>View` for `proto_fqn` in `current_package`
983/// would collide with another distinct FQN's alias in the same module
984/// (issue [#75]). Cross-package types whose short name is unique in this
985/// package's alias set keep their alias; only the colliding set is
986/// suppressed in favour of the inlined `OwnedView<…<'static>>` form.
987///
988/// [#75]: https://github.com/anthropics/connect-rust/issues/75
989fn alias_collides(batch: &BatchState, current_package: &str, proto_fqn: &str) -> bool {
990    let alias = owned_view_alias_ident(proto_fqn).to_string();
991    batch
992        .colliding_aliases
993        .contains(&(current_package.to_string(), alias))
994}
995
996/// Statement converting the Router-path `ServiceStream<OwnedView<…>>` into
997/// `StreamMessage<Req>` items before calling the handler. Applies to every
998/// input type, including ones mapped via `extern_path`: the backing
999/// `buffa::HasMessageView` impl is emitted by buffa's codegen in the crate
1000/// that owns the type (`extern_path` targets are required to be generated
1001/// with buffa ≥ 0.9.0 and views enabled).
1002fn router_stream_items_tokens(
1003    resolver: &TypeResolver<'_>,
1004    method: &MethodDescriptorProto,
1005    package: &str,
1006) -> TokenStream {
1007    let input_fqn = method.input_type.as_deref().unwrap_or("");
1008    // Panic on resolver errors like the surrounding route-registration code
1009    // does. (Threading `Result` through the registration builder is a
1010    // follow-up.)
1011    let input_owned = resolver
1012        .rust_type(input_fqn, package)
1013        .expect("rust_type failed for streaming input type");
1014    quote! {
1015        let req = ::connectrpc::dispatcher::codegen::into_stream_messages::<#input_owned>(req);
1016    }
1017}
1018
1019/// Doc lines describing the inbound stream item type on a client-streaming /
1020/// bidi trait method.
1021///
1022/// The yield-back sentence is only true when the method's input and output
1023/// types coincide (`StreamMessage<M>: Encodable<M>`), so it is emitted only
1024/// for echo-shaped methods.
1025fn stream_items_doc(method: &MethodDescriptorProto) -> TokenStream {
1026    let mut doc = quote! {
1027        #[doc = ""]
1028        #[doc = " Each `requests` item is a [`StreamMessage`](::connectrpc::StreamMessage):"]
1029        #[doc = " it owns its buffer, is `Send + 'static`, and exposes zero-copy"]
1030        #[doc = " accessor methods (`item.name()`), `.view()`, and"]
1031        #[doc = " `.to_owned_message()`."]
1032    };
1033    if method.input_type == method.output_type {
1034        doc.extend(quote! {
1035            #[doc = " Items can be yielded back unchanged"]
1036            #[doc = " (`StreamMessage<M>` implements `Encodable<M>`)."]
1037        });
1038    }
1039    doc
1040}
1041
1042/// Owned message type of a client-streaming / bidi RPC's inbound items;
1043/// the trait signature wraps it as `InboundStream<Req>`.
1044fn stream_owned_message_type(
1045    resolver: &TypeResolver<'_>,
1046    method: &MethodDescriptorProto,
1047    package: &str,
1048) -> Result<TokenStream> {
1049    let input_fqn = method.input_type.as_deref().unwrap_or("");
1050    let input_owned = resolver.rust_type(input_fqn, package)?;
1051    Ok(quote! { #input_owned })
1052}
1053
1054/// Walk every service's method input/output FQNs across `file_to_generate`
1055/// and identify `(package, alias_ident)` pairs where two or more distinct
1056/// FQNs would produce the same `Owned<Msg>View` alias in the same target
1057/// Rust module. Caller stores the result in [`BatchState::colliding_aliases`].
1058///
1059/// This pre-pass is what makes the alias emission collision-aware: a
1060/// per-file walk can't see same-short-name FQNs from sibling files in the
1061/// same package, but the stitcher mounts both into one module so the
1062/// collision is real (issue [#75]).
1063///
1064/// [#75]: https://github.com/anthropics/connect-rust/issues/75
1065fn collect_alias_collisions(
1066    proto_file: &[FileDescriptorProto],
1067    file_to_generate: &[String],
1068) -> std::collections::BTreeSet<(String, String)> {
1069    use std::collections::BTreeMap;
1070    // (package, alias_name) -> first FQN seen; subsequent distinct FQNs
1071    // mark the key as colliding.
1072    let mut first_seen: BTreeMap<(String, String), String> = BTreeMap::new();
1073    let mut colliding: std::collections::BTreeSet<(String, String)> =
1074        std::collections::BTreeSet::new();
1075
1076    for file_name in file_to_generate {
1077        let Some(file) = proto_file
1078            .iter()
1079            .find(|f| f.name.as_deref() == Some(file_name.as_str()))
1080        else {
1081            continue;
1082        };
1083        let package = file.package.clone().unwrap_or_default();
1084        for service in &file.service {
1085            for m in &service.method {
1086                for fqn in [m.input_type.as_deref(), m.output_type.as_deref()]
1087                    .into_iter()
1088                    .flatten()
1089                {
1090                    let alias = owned_view_alias_ident(fqn).to_string();
1091                    let key = (package.clone(), alias);
1092                    match first_seen.get(&key) {
1093                        Some(prev) if prev != fqn => {
1094                            colliding.insert(key);
1095                        }
1096                        Some(_) => {} // same FQN — fine, dedup catches it
1097                        None => {
1098                            first_seen.insert(key, fqn.to_string());
1099                        }
1100                    }
1101                }
1102            }
1103        }
1104    }
1105    colliding
1106}
1107
1108/// Emit `pub type Owned#{Msg}View = OwnedView<#{Msg}View<'static>>;` for
1109/// every distinct RPC input/output type referenced by services in this
1110/// file. The alias names the owned-view form of a message in handler code
1111/// (e.g. an `OwnedOutView` response body or a decoded client response).
1112///
1113/// Aliases whose name would collide with another distinct type's alias
1114/// in the same target package (per [`BatchState::colliding_aliases`]) are
1115/// suppressed — users spell the inlined `OwnedView<…<'static>>` form for
1116/// those types instead. This is the issue [#75] fix; the non-colliding
1117/// common case (including well-known types like `.google.protobuf.Empty`)
1118/// keeps its alias.
1119///
1120/// Deduped on `(package, fqn)` across the batch so two files in the same
1121/// package don't both emit the alias (E0428).
1122///
1123/// [#75]: https://github.com/anthropics/connect-rust/issues/75
1124fn generate_owned_view_aliases(
1125    file: &FileDescriptorProto,
1126    resolver: &TypeResolver<'_>,
1127    batch: &mut BatchState,
1128) -> Result<TokenStream> {
1129    let package = file.package.as_deref().unwrap_or("");
1130    let mut out = TokenStream::new();
1131    for service in &file.service {
1132        for m in &service.method {
1133            for fqn in [m.input_type.as_deref(), m.output_type.as_deref()]
1134                .into_iter()
1135                .flatten()
1136            {
1137                if alias_collides(batch, package, fqn) {
1138                    continue;
1139                }
1140                if !batch
1141                    .alias_seen
1142                    .insert((package.to_string(), fqn.to_string()))
1143                {
1144                    continue;
1145                }
1146                let alias = owned_view_alias_ident(fqn);
1147                let view = resolver.rust_view_type(fqn, package)?;
1148                let doc = format!(
1149                    "Shorthand for `OwnedView<{}View<'static>>`.",
1150                    bare_type_name(fqn).to_upper_camel_case()
1151                );
1152                out.extend(quote! {
1153                    #[doc = #doc]
1154                    pub type #alias = ::buffa::view::OwnedView<#view<'static>>;
1155                });
1156            }
1157        }
1158    }
1159    Ok(out)
1160}
1161
1162/// Emit `impl Encodable<M> for MView<'_>` and
1163/// `impl Encodable<M> for OwnedView<MView<'static>>` for every distinct
1164/// RPC output type not already in `batch.encodable_seen` (proto FQN).
1165///
1166/// These can't be runtime blankets (the `M: Message + Serialize` blanket
1167/// in `connectrpc::response` would conflict by coherence), so they're
1168/// emitted per concrete type. Orphan rules allow it because `M` (a local
1169/// type) appears in the trait parameters.
1170///
1171/// `batch.encodable_seen` is owned by the caller's batch loop so an
1172/// output type referenced from multiple input files only gets one impl
1173/// pair (the stitcher would otherwise hit E0119).
1174///
1175/// Skipped for output types that resolve to an absolute (`::`) extern
1176/// path, since those are foreign and would violate orphan rules.
1177fn generate_encodable_view_impls(
1178    file: &FileDescriptorProto,
1179    resolver: &TypeResolver<'_>,
1180    batch: &mut BatchState,
1181) -> Result<TokenStream> {
1182    let package = file.package.as_deref().unwrap_or("");
1183    let mut out = TokenStream::new();
1184    for service in &file.service {
1185        for m in &service.method {
1186            let fqn = m.output_type.as_deref().unwrap_or("");
1187            if let Some(impls) = encodable_impl_pair(fqn, package, resolver, batch)? {
1188                out.extend(impls);
1189            }
1190        }
1191    }
1192    Ok(out)
1193}
1194
1195/// Emit the `Encodable<M>` impl pair (plain view + `OwnedView`) for one
1196/// message FQN, or `None` when the pair was already emitted in this batch
1197/// or the type resolves to a foreign (`::`-rooted `extern_path`) crate —
1198/// there the impl would be an orphan.
1199fn encodable_impl_pair(
1200    fqn: &str,
1201    package: &str,
1202    resolver: &TypeResolver<'_>,
1203    batch: &mut BatchState,
1204) -> Result<Option<TokenStream>> {
1205    if !batch.encodable_seen.insert(fqn.to_string()) {
1206        return Ok(None);
1207    }
1208    let path = resolver.resolve_path(fqn, package)?;
1209    // Skip foreign types (extern_path → `::crate_name::...`): the
1210    // impl would be an orphan in the user's crate.
1211    if path.starts_with("::") {
1212        return Ok(None);
1213    }
1214    let owned = resolver.rust_type(fqn, package)?;
1215    let view = resolver.rust_view_type(fqn, package)?;
1216    Ok(Some(quote! {
1217        impl ::connectrpc::Encodable<#owned> for #view<'_> {
1218            fn encode(&self, codec: ::connectrpc::CodecFormat)
1219                -> ::std::result::Result<::buffa::bytes::Bytes, ::connectrpc::ConnectError>
1220            {
1221                ::connectrpc::__codegen::encode_view_body(self, codec)
1222            }
1223        }
1224        impl ::connectrpc::Encodable<#owned> for ::buffa::view::OwnedView<#view<'static>> {
1225            fn encode(&self, codec: ::connectrpc::CodecFormat)
1226                -> ::std::result::Result<::buffa::bytes::Bytes, ::connectrpc::ConnectError>
1227            {
1228                ::connectrpc::__codegen::encode_view_body(self.reborrow(), codec)
1229            }
1230
1231            /// An `OwnedView` still holds the buffer it was decoded from, so
1232            /// its large fields can be handed to the response body by
1233            /// reference count instead of copied. The bare view impl above
1234            /// cannot do this: it has borrows but no buffer to name.
1235            fn encode_segments(&self, codec: ::connectrpc::CodecFormat)
1236                -> ::std::result::Result<::connectrpc::EncodedBody, ::connectrpc::ConnectError>
1237            {
1238                ::connectrpc::__codegen::encode_view_body_segments(
1239                    self.reborrow(),
1240                    self.bytes(),
1241                    codec,
1242                )
1243            }
1244        }
1245    }))
1246}
1247
1248/// Emit `Encodable<M>` view impl pairs for **every message defined in
1249/// `file`** (recursing into nested messages, skipping synthetic map
1250/// entries), deduped through `batch.encodable_seen` against the
1251/// service-output-driven emission in [`generate_encodable_view_impls`].
1252///
1253/// Driven by [`EncodableImpls::AllMessages`]: in a multi-crate
1254/// layout this runs in the crate that owns the messages, so other crates'
1255/// service stubs (which must skip these foreign types — orphan rules) can
1256/// still hand views of them to the response path.
1257fn generate_all_message_encodable_impls(
1258    file: &FileDescriptorProto,
1259    resolver: &TypeResolver<'_>,
1260    batch: &mut BatchState,
1261) -> Result<TokenStream> {
1262    fn recurse(
1263        msg: &DescriptorProto,
1264        fqn_prefix: &str,
1265        package: &str,
1266        resolver: &TypeResolver<'_>,
1267        batch: &mut BatchState,
1268        out: &mut TokenStream,
1269    ) -> Result<()> {
1270        // Synthetic map-entry messages have no generated Rust type.
1271        if msg
1272            .options
1273            .as_option()
1274            .is_some_and(|o| o.map_entry.unwrap_or(false))
1275        {
1276            return Ok(());
1277        }
1278        let name = msg.name.as_deref().unwrap_or("");
1279        let fqn = format!("{fqn_prefix}.{name}");
1280        if let Some(impls) = encodable_impl_pair(&fqn, package, resolver, batch)? {
1281            out.extend(impls);
1282        }
1283        for nested in &msg.nested_type {
1284            recurse(nested, &fqn, package, resolver, batch, out)?;
1285        }
1286        Ok(())
1287    }
1288
1289    let package = file.package.as_deref().unwrap_or("");
1290    let fqn_prefix = if package.is_empty() {
1291        String::new()
1292    } else {
1293        format!(".{package}")
1294    };
1295    let mut out = TokenStream::new();
1296    for msg in &file.message_type {
1297        recurse(msg, &fqn_prefix, package, resolver, batch, &mut out)?;
1298    }
1299    Ok(out)
1300}
1301
1302/// Generate code for a single service.
1303/// Reject RPC method sets whose generated Rust identifiers collide.
1304///
1305/// Each proto method `Foo` produces `foo` and `foo_with_options` on the
1306/// client and the module-scope constant `{SVC}_FOO_SPEC`. Two methods that
1307/// normalize to the same snake_case name (e.g. `GetFoo` and `get_foo`), or
1308/// one whose snake form equals another's plus a generated suffix (`Get` +
1309/// `GetWithOptions`; `Get` + `GetSpec`), would emit duplicate definitions
1310/// and fail to compile with an error pointing at generated code rather than
1311/// the proto.
1312fn check_method_collisions(service_name: &str, service: &ServiceDescriptorProto) -> Result<()> {
1313    let mut seen: HashMap<String, String> = HashMap::new();
1314    for m in &service.method {
1315        let proto_name = m.name.as_deref().unwrap_or("");
1316        let snake = proto_name.to_snake_case();
1317        let idents = [snake.clone(), format!("{snake}_with_options")];
1318        for ident in &idents {
1319            if let Some(prev) = seen.get(ident) {
1320                anyhow::bail!(
1321                    "service {service_name}: RPC methods {prev:?} and {proto_name:?} \
1322                     both generate Rust identifier `{ident}`; rename one in the proto"
1323                );
1324            }
1325        }
1326        for ident in idents {
1327            seen.insert(ident, proto_name.to_string());
1328        }
1329    }
1330    Ok(())
1331}
1332
1333fn generate_service(
1334    file: &FileDescriptorProto,
1335    service: &ServiceDescriptorProto,
1336    resolver: &TypeResolver<'_>,
1337    batch: &BatchState,
1338) -> Result<TokenStream> {
1339    let package = file.package.as_deref().unwrap_or("");
1340    let service_name = service.name.as_deref().unwrap_or("");
1341    check_method_collisions(service_name, service)?;
1342    // Empty package is valid proto; the fully-qualified service name is just
1343    // `ServiceName`, not `.ServiceName` (which would break interop).
1344    let full_service_name = if package.is_empty() {
1345        service_name.to_string()
1346    } else {
1347        format!("{package}.{service_name}")
1348    };
1349    let service_upper = service_name.to_upper_camel_case();
1350    // `Self` is the only PascalCase Rust keyword, and cannot be a raw ident;
1351    // suffix it so `service Self {}` (accepted by protoc) generates a valid
1352    // trait. The suffixed derivatives below are already keyword-safe.
1353    let trait_name = if service_upper == "Self" {
1354        format_ident!("Self_")
1355    } else {
1356        format_ident!("{}", service_upper)
1357    };
1358    let ext_trait_name = format_ident!("{}Ext", service_upper);
1359    let register_marker_name = format_ident!("{}RegisterMarker", service_upper);
1360    let client_name = format_ident!("{}Client", service_upper);
1361    let server_name = format_ident!("{}Server", service_upper);
1362    let service_name_const = format_ident!(
1363        "{}_SERVICE_NAME",
1364        service_name.to_snake_case().to_uppercase()
1365    );
1366
1367    // Get service documentation and append async impl guidance
1368    let service_doc = get_service_comment(file, service).unwrap_or_default();
1369    let base_doc = if service_doc.is_empty() {
1370        format!("Server trait for {service_name}.")
1371    } else {
1372        service_doc
1373    };
1374    let full_doc = format!(
1375        "{base_doc}\n\n\
1376         # Implementing handlers\n\n\
1377         Implement methods with plain `async fn`; the returned future satisfies\n\
1378         the `Send` bound automatically.\n\n\
1379         **Unary and server-streaming requests** arrive as\n\
1380         [`ServiceRequest<'_, Req>`](::connectrpc::ServiceRequest): a zero-copy\n\
1381         view of the request plus its body, valid for the duration of the call.\n\
1382         Fields are read directly (`request.name` is a `&str` into the decoded\n\
1383         buffer) and the borrow may be held across `.await` points. Anything\n\
1384         that must outlive the call — `tokio::spawn`, channels, server state,\n\
1385         or data captured by a returned response stream — takes owned data:\n\
1386         call `request.to_owned_message()` (or copy the specific fields)\n\
1387         first.\n\n\
1388         **Client-streaming and bidi requests** arrive as\n\
1389         [`InboundStream<Req>`](::connectrpc::InboundStream) — a\n\
1390         `ServiceStream` of [`StreamMessage`](::connectrpc::StreamMessage)s.\n\
1391         Each item owns its decoded buffer and is `Send + 'static`, so items\n\
1392         can be buffered or moved into spawned tasks; read fields zero-copy\n\
1393         through the generated accessor methods (`item.name()`) or `.view()`,\n\
1394         convert with `.to_owned_message()`, or yield an item back unchanged —\n\
1395         `StreamMessage<M>` implements `Encodable<M>`.\n\n\
1396         Request types resolved through `extern_path` (e.g. well-known types\n\
1397         from another crate) use the same wrappers; the crate that owns the\n\
1398         type must be generated with buffa ≥ 0.9.0 and views enabled so the\n\
1399         backing `HasMessageView` impl exists.\n\n\
1400         The `impl Encodable<Out>` return bound accepts the owned `Out`, the\n\
1401         generated `OutView<'_>` / `OwnedOutView`,\n\
1402         [`MaybeBorrowed`](::connectrpc::MaybeBorrowed), or\n\
1403         [`PreEncoded`](::connectrpc::PreEncoded) for handlers that encode a\n\
1404         non-`'static` view internally and pass the bytes across the handler\n\
1405         boundary. View bodies are not emitted for output types mapped via\n\
1406         `extern_path` (the impl would be an orphan); return owned for\n\
1407         WKT/extern outputs.\n\n\
1408         Server-streaming and bidi-streaming methods return\n\
1409         `ServiceStream<impl Encodable<Out> + Send + use<Self>>`. The\n\
1410         `use<Self>` precise-capturing clause excludes `&self`'s lifetime and\n\
1411         the request's lifetime (unary methods use `use<'a, Self>` and may\n\
1412         borrow from `&self`), so stream items must be `'static` and cannot\n\
1413         borrow from the request. To stream view-encoded data, encode each\n\
1414         item inside the stream body and yield\n\
1415         [`PreEncoded`](::connectrpc::PreEncoded) — see its `# Streaming\n\
1416         example` doc."
1417    );
1418    let service_doc_tokens = doc_attrs(&full_doc);
1419
1420    // Generate trait methods
1421    let trait_methods: Vec<TokenStream> = service
1422        .method
1423        .iter()
1424        .map(|m| generate_trait_method(file, service, m, resolver, package))
1425        .collect::<Result<Vec<_>>>()?;
1426
1427    // Generate route registrations for extension trait
1428    let route_registrations: Vec<TokenStream> = service
1429        .method
1430        .iter()
1431        .map(|m| {
1432            let method_name = m.name.as_deref().unwrap_or("");
1433            let method_snake = make_field_ident(&method_name.to_snake_case());
1434            // Attach the per-method `Spec` const so the dynamic `Router`
1435            // surfaces `RequestContext::spec()` exactly like the
1436            // monomorphic `FooServiceServer<T>` dispatcher does.
1437            let spec_const = method_spec_const_ident(service, method_name);
1438
1439            let client_streaming = m.client_streaming.unwrap_or(false);
1440            let server_streaming = m.server_streaming.unwrap_or(false);
1441
1442            let route_call = if server_streaming && !client_streaming {
1443                // Server streaming method. The trait method returns
1444                // `ServiceStream<impl Encodable<Out>>`; `Res = Out` is no
1445                // longer derivable from the opaque item type, so it must
1446                // be turbofished.
1447                let output_type = resolver
1448                    .rust_type(m.output_type.as_deref().unwrap_or(""), package)
1449                    .unwrap();
1450                let input_fqn = m.input_type.as_deref().unwrap_or("");
1451                let input_view = resolver.rust_view_type(input_fqn, package).unwrap();
1452                let input_owned = resolver.rust_type(input_fqn, package).unwrap();
1453                let call_handler = quote! {
1454                    let sreq = ::connectrpc::ServiceRequest::<#input_owned>::from_parts(req.reborrow(), req.bytes());
1455                    svc.#method_snake(ctx, sreq).await
1456                };
1457                quote! {
1458                    .route_view_server_stream::<_, _, #output_type>(
1459                        #service_name_const,
1460                        #method_name,
1461                        ::connectrpc::view_streaming_handler_fn({
1462                            let svc = ::std::sync::Arc::clone(&self);
1463                            move |ctx, req: ::buffa::view::OwnedView<#input_view<'static>>| {
1464                                let svc = ::std::sync::Arc::clone(&svc);
1465                                async move {
1466                                    // `req` (an OwnedView) is owned by this future; the
1467                                    // handler borrows from it until it returns the stream.
1468                                    #call_handler
1469                                }
1470                            }
1471                        }),
1472                    )
1473                }
1474            } else if client_streaming && !server_streaming {
1475                // Client streaming method
1476                let output_type = resolver
1477                    .rust_type(m.output_type.as_deref().unwrap_or(""), package)
1478                    .unwrap();
1479                let into_items = router_stream_items_tokens(resolver, m, package);
1480                quote! {
1481                    .route_view_client_stream(
1482                        #service_name_const,
1483                        #method_name,
1484                        ::connectrpc::view_client_streaming_handler_fn({
1485                            let svc = ::std::sync::Arc::clone(&self);
1486                            move |ctx, req, format| {
1487                                let svc = ::std::sync::Arc::clone(&svc);
1488                                async move {
1489                                    #into_items
1490                                    svc.#method_snake(ctx, req).await?.encode::<#output_type>(format)
1491                                }
1492                            }
1493                        }),
1494                    )
1495                }
1496            } else if client_streaming && server_streaming {
1497                // Bidi streaming method. Same turbofish need as server
1498                // streaming above.
1499                let output_type = resolver
1500                    .rust_type(m.output_type.as_deref().unwrap_or(""), package)
1501                    .unwrap();
1502                let into_items = router_stream_items_tokens(resolver, m, package);
1503                quote! {
1504                    .route_view_bidi_stream::<_, _, #output_type>(
1505                        #service_name_const,
1506                        #method_name,
1507                        ::connectrpc::view_bidi_streaming_handler_fn({
1508                            let svc = ::std::sync::Arc::clone(&self);
1509                            move |ctx, req| {
1510                                let svc = ::std::sync::Arc::clone(&svc);
1511                                async move {
1512                                    #into_items
1513                                    svc.#method_snake(ctx, req).await
1514                                }
1515                            }
1516                        }),
1517                    )
1518                }
1519            } else {
1520                // Unary method
1521                let is_idempotent = m
1522                    .options
1523                    .idempotency_level
1524                    .map(|level| level == IdempotencyLevel::NO_SIDE_EFFECTS)
1525                    .unwrap_or(false);
1526
1527                let route_method = if is_idempotent {
1528                    quote! { route_view_idempotent }
1529                } else {
1530                    quote! { route_view }
1531                };
1532                let output_type = resolver
1533                    .rust_type(m.output_type.as_deref().unwrap_or(""), package)
1534                    .unwrap();
1535                // The closure parameter is annotated because the handler now
1536                // takes a borrowed request, so `ReqView` is no longer
1537                // inferable from the call alone.
1538                let input_fqn = m.input_type.as_deref().unwrap_or("");
1539                let input_view = resolver.rust_view_type(input_fqn, package).unwrap();
1540                let input_owned = resolver.rust_type(input_fqn, package).unwrap();
1541                let call_handler = quote! {
1542                    let sreq = ::connectrpc::ServiceRequest::<#input_owned>::from_parts(req.reborrow(), req.bytes());
1543                    svc.#method_snake(ctx, sreq).await?.encode::<#output_type>(format)
1544                };
1545
1546                quote! {
1547                    .#route_method(
1548                        #service_name_const,
1549                        #method_name,
1550                        {
1551                            let svc = ::std::sync::Arc::clone(&self);
1552                            ::connectrpc::view_handler_fn(move |ctx, req: ::buffa::view::OwnedView<#input_view<'static>>, format| {
1553                                let svc = ::std::sync::Arc::clone(&svc);
1554                                async move {
1555                                    // `req` (an OwnedView) is owned by this future; the
1556                                    // handler borrows from it for the call.
1557                                    #call_handler
1558                                }
1559                            })
1560                        },
1561                    )
1562                }
1563            };
1564
1565            quote! {
1566                #route_call
1567                .with_spec(#spec_const)
1568            }
1569        })
1570        .collect();
1571
1572    // Generate client methods
1573    let client_methods: Vec<TokenStream> = service
1574        .method
1575        .iter()
1576        .map(|m| generate_client_method(service, &full_service_name, m, resolver, package))
1577        .collect::<Result<Vec<_>>>()?;
1578
1579    // Generate monomorphic FooServiceServer<T> dispatcher.
1580    let service_server = generate_service_server(
1581        &full_service_name,
1582        &trait_name,
1583        &server_name,
1584        service,
1585        resolver,
1586        package,
1587    )?;
1588
1589    // Example method name for client doc
1590    let example_method = service
1591        .method
1592        .first()
1593        .and_then(|m| m.name.as_deref())
1594        .map(|n| make_field_ident(&n.to_snake_case()).to_string())
1595        .unwrap_or_else(|| "method".to_string());
1596
1597    // Build client doc comment with interpolated example method
1598    let client_name_str = client_name.to_string();
1599    let client_doc = format!(
1600        r#"Client for this service.
1601
1602Generic over `T: ClientTransport`. For **gRPC** (HTTP/2), use
1603`Http2Connection` — it has honest `poll_ready` and composes with
1604`tower::balance` for multi-connection load balancing. For **Connect
1605over HTTP/1.1** (or unknown protocol), use `HttpClient`.
1606
1607# Example (gRPC / HTTP/2)
1608
1609```rust,ignore
1610use connectrpc::client::{{Http2Connection, ClientConfig}};
1611use connectrpc::Protocol;
1612
1613let uri: http::Uri = "http://localhost:8080".parse()?;
1614let conn = Http2Connection::connect_plaintext(uri.clone()).await?.shared(1024);
1615let config = ClientConfig::new(uri).with_protocol(Protocol::Grpc);
1616
1617let client = {client_name_str}::new(conn, config);
1618let response = client.{example_method}(request).await?;
1619```
1620
1621# Example (Connect / HTTP/1.1 or ALPN)
1622
1623```rust,ignore
1624use connectrpc::client::{{HttpClient, ClientConfig}};
1625
1626let http = HttpClient::plaintext();  // cleartext http:// only
1627let config = ClientConfig::new("http://localhost:8080".parse()?);
1628
1629let client = {client_name_str}::new(http, config);
1630let response = client.{example_method}(request).await?;
1631```
1632
1633# Working with the response
1634
1635Unary calls return [`UnaryResponse<OwnedView<FooView>>`](::connectrpc::client::UnaryResponse).
1636[`view()`](::connectrpc::client::UnaryResponse::view) borrows the response
1637message, so field access is zero-copy:
1638
1639```rust,ignore
1640let resp = client.{example_method}(request).await?;
1641let name: &str = resp.view().name;  // borrow into the response buffer
1642```
1643
1644If you need the owned struct (e.g. to store or pass by value), use
1645[`into_owned()`](::connectrpc::client::UnaryResponse::into_owned):
1646
1647```rust,ignore
1648let owned = client.{example_method}(request).await?.into_owned();
1649```
1650
1651[`into_view()`](::connectrpc::client::UnaryResponse::into_view) keeps the
1652zero-copy decoded body (an `OwnedView`) without copying; field access on it
1653goes through `.reborrow()`. Streaming responses yield one
1654[`StreamMessage`](::connectrpc::StreamMessage) per received message from
1655`.message().await` — read fields zero-copy through the generated accessor
1656methods (`msg.name()`) or `.view()`, or convert with `.to_owned_message()`."#
1657    );
1658    let client_doc_tokens = doc_attrs(&client_doc);
1659    // Opt-in feature cfg on every client-side item.
1660    //
1661    // INVARIANT: any future emission referencing
1662    // `::connectrpc::client::*` (an additional `impl`, a free fn, a
1663    // sibling trait, …) must also be prefixed with `#client_cfg_attr`.
1664    // The `no_ungated_client_references` test enforces this by scanning
1665    // the formatted output under the opt-in path.
1666    let client_cfg_attr: TokenStream = if batch.gate_client_feature {
1667        let feature_name = syn::LitStr::new(batch.client_feature_name(), Span::call_site());
1668        quote! { #[cfg(feature = #feature_name)] }
1669    } else {
1670        TokenStream::new()
1671    };
1672
1673    // Per-method `Spec` constants. Stable, allocation-free metadata that the
1674    // dispatcher threads into `RequestContext::spec`, that generated client
1675    // methods pass to `call_*` (with `origin` flipped to `Client`), and that
1676    // user code can reference directly.
1677    let spec_consts = generate_spec_consts(&full_service_name, service);
1678
1679    Ok(quote! {
1680        // -----------------------------------------------------------------------------
1681        // #service_name
1682        // -----------------------------------------------------------------------------
1683
1684        /// Full service name for this service.
1685        pub const #service_name_const: &str = #full_service_name;
1686
1687        #(#spec_consts)*
1688
1689        #service_doc_tokens
1690        #[allow(clippy::type_complexity)]
1691        pub trait #trait_name: Send + Sync + 'static {
1692            #(#trait_methods)*
1693        }
1694
1695        /// Extension trait for registering a service implementation with a Router.
1696        ///
1697        /// This trait is automatically implemented for all types that implement the service trait.
1698        /// Prefer [`Router::add_service`](::connectrpc::Router::add_service) for
1699        /// top-down registration; `register` remains available for compatibility
1700        /// and cases where the service-first call shape is more convenient.
1701        ///
1702        /// # Example
1703        ///
1704        /// ```rust,ignore
1705        /// use std::sync::Arc;
1706        ///
1707        /// let service = Arc::new(MyServiceImpl);
1708        /// let router = service.register(Router::new());
1709        /// ```
1710        pub trait #ext_trait_name: #trait_name {
1711            /// Register this service implementation with a Router.
1712            ///
1713            /// Takes ownership of the `Arc<Self>` and returns a new Router with
1714            /// this service's methods registered.
1715            fn register(self: ::std::sync::Arc<Self>, router: ::connectrpc::Router) -> ::connectrpc::Router;
1716        }
1717
1718        impl<S: #trait_name> #ext_trait_name for S {
1719            fn register(self: ::std::sync::Arc<Self>, router: ::connectrpc::Router) -> ::connectrpc::Router {
1720                router
1721                    #(#route_registrations)*
1722            }
1723        }
1724
1725        /// Type-inference marker used by [`Router::add_service`](::connectrpc::Router::add_service).
1726        #[doc(hidden)]
1727        pub struct #register_marker_name;
1728
1729        impl<S: #trait_name> ::connectrpc::ServiceRegister<#register_marker_name>
1730            for ::std::sync::Arc<S>
1731        {
1732            fn register_service(self, router: ::connectrpc::Router) -> ::connectrpc::Router {
1733                <S as #ext_trait_name>::register(self, router)
1734            }
1735        }
1736
1737        #service_server
1738
1739        #client_doc_tokens
1740        #client_cfg_attr
1741        #[derive(Clone)]
1742        pub struct #client_name<T> {
1743            transport: T,
1744            config: ::connectrpc::client::ClientConfig,
1745        }
1746
1747        #client_cfg_attr
1748        impl<T> #client_name<T>
1749        where
1750            T: ::connectrpc::client::ClientTransport,
1751            <T::ResponseBody as ::connectrpc::http_body::Body>::Error: ::std::fmt::Display,
1752        {
1753            /// Create a new client with the given transport and configuration.
1754            pub fn new(transport: T, config: ::connectrpc::client::ClientConfig) -> Self {
1755                Self { transport, config }
1756            }
1757
1758            /// Get the client configuration.
1759            pub fn config(&self) -> &::connectrpc::client::ClientConfig {
1760                &self.config
1761            }
1762
1763            /// Get a mutable reference to the client configuration.
1764            pub fn config_mut(&mut self) -> &mut ::connectrpc::client::ClientConfig {
1765                &mut self.config
1766            }
1767
1768            #(#client_methods)*
1769        }
1770    })
1771}
1772
1773/// Construct the identifier for the per-method `Spec` constant,
1774/// `{SERVICE}_{METHOD}_SPEC`, e.g. `ELIZA_SERVICE_SAY_SPEC` for
1775/// `ElizaService.Say`. Referenced by the generated `Dispatcher::lookup` and,
1776/// with `.with_origin(SpecOrigin::Client)`, by generated client methods.
1777fn method_spec_const_ident(service: &ServiceDescriptorProto, method_name: &str) -> Ident {
1778    let service_name = service.name.as_deref().unwrap_or("");
1779    format_ident!(
1780        "{}_{}_SPEC",
1781        service_name.to_snake_case().to_uppercase(),
1782        method_name.to_snake_case().to_uppercase()
1783    )
1784}
1785
1786/// Emit one `pub const … : ::connectrpc::Spec` per method.
1787///
1788/// Each constant captures the method's procedure path, stream type, and
1789/// idempotency level, constructed via `Spec::server(...)`. It is the single
1790/// source of a method's static facts: the dispatcher surfaces it on
1791/// `RequestContext::spec`, and generated client methods pass the same
1792/// constant with `origin` flipped to `Client`.
1793fn generate_spec_consts(
1794    full_service_name: &str,
1795    service: &ServiceDescriptorProto,
1796) -> Vec<TokenStream> {
1797    service
1798        .method
1799        .iter()
1800        .map(|m| {
1801            let method_name = m.name.as_deref().unwrap_or("");
1802            let spec_const = method_spec_const_ident(service, method_name);
1803            let procedure = format!("/{full_service_name}/{method_name}");
1804            let cs = m.client_streaming.unwrap_or(false);
1805            let ss = m.server_streaming.unwrap_or(false);
1806            let stream_type = match (cs, ss) {
1807                (true, true) => quote! { ::connectrpc::StreamType::BidiStream },
1808                (true, false) => quote! { ::connectrpc::StreamType::ClientStream },
1809                (false, true) => quote! { ::connectrpc::StreamType::ServerStream },
1810                (false, false) => quote! { ::connectrpc::StreamType::Unary },
1811            };
1812            let idempotency_level = match m.options.idempotency_level {
1813                Some(IdempotencyLevel::NO_SIDE_EFFECTS) => {
1814                    quote! { ::connectrpc::IdempotencyLevel::NoSideEffects }
1815                }
1816                Some(IdempotencyLevel::IDEMPOTENT) => {
1817                    quote! { ::connectrpc::IdempotencyLevel::Idempotent }
1818                }
1819                _ => quote! { ::connectrpc::IdempotencyLevel::Unknown },
1820            };
1821            let doc = doc_attrs(&format!(
1822                "Static [`Spec`](::connectrpc::Spec) for the `{method_name}` RPC, as seen \
1823                 by the server; the generated client passes it with \
1824                 [`origin`](::connectrpc::Spec::origin) `Client` (compare across sides with \
1825                 [`Spec::same_method`](::connectrpc::Spec::same_method))."
1826            ));
1827            quote! {
1828                #doc
1829                pub const #spec_const: ::connectrpc::Spec =
1830                    ::connectrpc::Spec::server(#procedure, #stream_type)
1831                        .with_idempotency_level(#idempotency_level);
1832            }
1833        })
1834        .collect()
1835}
1836
1837/// Generate a monomorphic `FooServiceServer<T>` struct and its `Dispatcher` impl.
1838///
1839/// This is the fast-path alternative to `FooServiceExt::register(Router)`: instead
1840/// of type-erasing each method behind `Arc<dyn ErasedHandler>` and looking them up
1841/// in a `HashMap`, this struct dispatches via a compile-time `match` on method name
1842/// with no trait objects or hash lookups in the hot path.
1843fn generate_service_server(
1844    full_service_name: &str,
1845    trait_name: &proc_macro2::Ident,
1846    server_name: &proc_macro2::Ident,
1847    service: &ServiceDescriptorProto,
1848    resolver: &TypeResolver<'_>,
1849    package: &str,
1850) -> Result<TokenStream> {
1851    // Path prefix matched by `dispatch` / `call_*`: "pkg.Service/"
1852    let path_prefix = format!("{full_service_name}/");
1853
1854    // Per-method match arms for `lookup(path)`.
1855    let lookup_arms: Vec<TokenStream> = service
1856        .method
1857        .iter()
1858        .map(|m| {
1859            let method_name = m.name.as_deref().unwrap_or("");
1860            let client_streaming = m.client_streaming.unwrap_or(false);
1861            let server_streaming = m.server_streaming.unwrap_or(false);
1862            let is_idempotent = m
1863                .options
1864                .idempotency_level
1865                .map(|level| level == IdempotencyLevel::NO_SIDE_EFFECTS)
1866                .unwrap_or(false);
1867            let spec_const = method_spec_const_ident(service, method_name);
1868
1869            let desc = if client_streaming && server_streaming {
1870                quote! { ::connectrpc::dispatcher::codegen::MethodDescriptor::bidi_streaming() }
1871            } else if client_streaming {
1872                quote! { ::connectrpc::dispatcher::codegen::MethodDescriptor::client_streaming() }
1873            } else if server_streaming {
1874                quote! { ::connectrpc::dispatcher::codegen::MethodDescriptor::server_streaming() }
1875            } else {
1876                quote! { ::connectrpc::dispatcher::codegen::MethodDescriptor::unary(#is_idempotent) }
1877            };
1878            quote! { #method_name => Some(#desc.with_spec(#spec_const)), }
1879        })
1880        .collect();
1881
1882    // Per-kind match arms for the four `call_*` methods.
1883    // Each `call_*` only includes arms for methods of the matching kind; other
1884    // paths fall through to `unimplemented_*` (the caller checked `lookup()`
1885    // first, so this is a defensive-only branch).
1886    let mut call_unary_arms: Vec<TokenStream> = Vec::new();
1887    let mut call_ss_arms: Vec<TokenStream> = Vec::new();
1888    let mut call_cs_arms: Vec<TokenStream> = Vec::new();
1889    let mut call_bidi_arms: Vec<TokenStream> = Vec::new();
1890
1891    for m in &service.method {
1892        let method_name = m.name.as_deref().unwrap_or("");
1893        let method_snake = make_field_ident(&method_name.to_snake_case());
1894        let input_view = resolver.rust_view_type(m.input_type.as_deref().unwrap_or(""), package)?;
1895        let output_type = resolver.rust_type(m.output_type.as_deref().unwrap_or(""), package)?;
1896        let cs = m.client_streaming.unwrap_or(false);
1897        let ss = m.server_streaming.unwrap_or(false);
1898
1899        // Inbound stream decoding for client-streaming / bidi: typed
1900        // `StreamMessage<Req>` items.
1901        let stream_decode = {
1902            let input_fqn = m.input_type.as_deref().unwrap_or("");
1903            let input_owned = resolver.rust_type(input_fqn, package)?;
1904            quote! { ::connectrpc::dispatcher::codegen::decode_message_request_stream::<#input_owned>(requests, format, ctx.decode_options().clone()) }
1905        };
1906
1907        if cs && ss {
1908            // Bidi streaming
1909            call_bidi_arms.push(quote! {
1910                #method_name => {
1911                    let svc = ::std::sync::Arc::clone(&self.inner);
1912                    Box::pin(async move {
1913                        let req_stream = #stream_decode;
1914                        let resp = svc.#method_snake(ctx, req_stream).await?;
1915                        Ok(resp.map_body(|s| ::connectrpc::dispatcher::codegen::encode_response_stream::<#output_type, _, _>(s, format)))
1916                    })
1917                }
1918            });
1919        } else if cs {
1920            // Client streaming
1921            call_cs_arms.push(quote! {
1922                #method_name => {
1923                    let svc = ::std::sync::Arc::clone(&self.inner);
1924                    Box::pin(async move {
1925                        let req_stream = #stream_decode;
1926                        svc.#method_snake(ctx, req_stream).await?.encode::<#output_type>(format)
1927                    })
1928                }
1929            });
1930        } else if ss {
1931            // Server streaming
1932            let input_fqn = m.input_type.as_deref().unwrap_or("");
1933            let input_owned = resolver.rust_type(input_fqn, package)?;
1934            let call_handler = quote! {
1935                let req = ::connectrpc::ServiceRequest::<#input_owned>::from_parts(&req, &body);
1936                let resp = svc.#method_snake(ctx, req).await?;
1937            };
1938            call_ss_arms.push(quote! {
1939                #method_name => {
1940                    let svc = ::std::sync::Arc::clone(&self.inner);
1941                    Box::pin(async move {
1942                        // The normalized body is owned by this future; the handler
1943                        // borrows from it until it returns the response stream.
1944                        let body = ::connectrpc::dispatcher::codegen::request_proto_bytes::<#input_owned>(request, format)?;
1945                        let req: #input_view<'_> = ::connectrpc::dispatcher::codegen::decode_borrowed_request_view(&body, ctx.decode_options())?;
1946                        #call_handler
1947                        Ok(resp.map_body(|s| ::connectrpc::dispatcher::codegen::encode_response_stream::<#output_type, _, _>(s, format)))
1948                    })
1949                }
1950            });
1951        } else {
1952            // Unary
1953            let input_fqn = m.input_type.as_deref().unwrap_or("");
1954            let input_owned = resolver.rust_type(input_fqn, package)?;
1955            let call_handler = quote! {
1956                let req = ::connectrpc::ServiceRequest::<#input_owned>::from_parts(&req, &body);
1957                svc.#method_snake(ctx, req).await?.encode::<#output_type>(format)
1958            };
1959            call_unary_arms.push(quote! {
1960                #method_name => {
1961                    let svc = ::std::sync::Arc::clone(&self.inner);
1962                    Box::pin(async move {
1963                        // Generated handlers are view-based, so the owned-message
1964                        // cache an interceptor may have populated cannot be reused.
1965                        // `encoded()` returns the (post-replacement) wire bytes —
1966                        // a cheap `Bytes` clone for the common no-replacement case.
1967                        // The normalized body is owned by this future; the handler
1968                        // borrows from it for the duration of the call.
1969                        let body = ::connectrpc::dispatcher::codegen::request_proto_bytes::<#input_owned>(request.encoded()?, format)?;
1970                        let req: #input_view<'_> = ::connectrpc::dispatcher::codegen::decode_borrowed_request_view(&body, ctx.decode_options())?;
1971                        #call_handler
1972                    })
1973                }
1974            });
1975        }
1976    }
1977
1978    let server_doc = format!(
1979        "Monomorphic dispatcher for `{trait_name}`.\n\n\
1980         Unlike `.register(Router)` which type-erases each method into an \
1981         `Arc<dyn ErasedHandler>` stored in a `HashMap`, this struct dispatches \
1982         via a compile-time `match` on method name: no vtable, no hash lookup.\n\n\
1983         # Example\n\n\
1984         ```rust,ignore\n\
1985         use connectrpc::ConnectRpcService;\n\n\
1986         let server = {server_name}::new(MyImpl);\n\
1987         let service = ConnectRpcService::new(server);\n\
1988         // hand `service` to axum/hyper as a fallback_service\n\
1989         ```"
1990    );
1991    let server_doc_tokens = doc_attrs(&server_doc);
1992
1993    Ok(quote! {
1994        #server_doc_tokens
1995        pub struct #server_name<T> {
1996            inner: ::std::sync::Arc<T>,
1997        }
1998
1999        impl<T: #trait_name> #server_name<T> {
2000            /// Wrap a service implementation in a monomorphic dispatcher.
2001            pub fn new(service: T) -> Self {
2002                Self { inner: ::std::sync::Arc::new(service) }
2003            }
2004
2005            /// Wrap an already-`Arc`'d service implementation.
2006            pub fn from_arc(inner: ::std::sync::Arc<T>) -> Self {
2007                Self { inner }
2008            }
2009        }
2010
2011        impl<T> Clone for #server_name<T> {
2012            fn clone(&self) -> Self {
2013                Self { inner: ::std::sync::Arc::clone(&self.inner) }
2014            }
2015        }
2016
2017        impl<T: #trait_name> ::connectrpc::Dispatcher for #server_name<T> {
2018            #[inline]
2019            fn lookup(&self, path: &str) -> Option<::connectrpc::dispatcher::codegen::MethodDescriptor> {
2020                let method = path.strip_prefix(#path_prefix)?;
2021                match method {
2022                    #(#lookup_arms)*
2023                    _ => None,
2024                }
2025            }
2026
2027            fn call_unary(
2028                &self,
2029                path: &str,
2030                ctx: ::connectrpc::RequestContext,
2031                request: ::connectrpc::Payload,
2032                format: ::connectrpc::CodecFormat,
2033            ) -> ::connectrpc::dispatcher::codegen::UnaryResult {
2034                let Some(method) = path.strip_prefix(#path_prefix) else {
2035                    return ::connectrpc::dispatcher::codegen::unimplemented_unary(path);
2036                };
2037                // Suppress unused warnings when this service has no unary methods.
2038                let _ = (&ctx, &request, &format);
2039                match method {
2040                    #(#call_unary_arms)*
2041                    _ => ::connectrpc::dispatcher::codegen::unimplemented_unary(path),
2042                }
2043            }
2044
2045            fn call_server_streaming(
2046                &self,
2047                path: &str,
2048                ctx: ::connectrpc::RequestContext,
2049                request: ::buffa::bytes::Bytes,
2050                format: ::connectrpc::CodecFormat,
2051            ) -> ::connectrpc::dispatcher::codegen::StreamingResult {
2052                let Some(method) = path.strip_prefix(#path_prefix) else {
2053                    return ::connectrpc::dispatcher::codegen::unimplemented_streaming(path);
2054                };
2055                let _ = (&ctx, &request, &format);
2056                match method {
2057                    #(#call_ss_arms)*
2058                    _ => ::connectrpc::dispatcher::codegen::unimplemented_streaming(path),
2059                }
2060            }
2061
2062            fn call_client_streaming(
2063                &self,
2064                path: &str,
2065                ctx: ::connectrpc::RequestContext,
2066                requests: ::connectrpc::dispatcher::codegen::RequestStream,
2067                format: ::connectrpc::CodecFormat,
2068            ) -> ::connectrpc::dispatcher::codegen::UnaryResult {
2069                let Some(method) = path.strip_prefix(#path_prefix) else {
2070                    return ::connectrpc::dispatcher::codegen::unimplemented_unary(path);
2071                };
2072                let _ = (&ctx, &requests, &format);
2073                match method {
2074                    #(#call_cs_arms)*
2075                    _ => ::connectrpc::dispatcher::codegen::unimplemented_unary(path),
2076                }
2077            }
2078
2079            fn call_bidi_streaming(
2080                &self,
2081                path: &str,
2082                ctx: ::connectrpc::RequestContext,
2083                requests: ::connectrpc::dispatcher::codegen::RequestStream,
2084                format: ::connectrpc::CodecFormat,
2085            ) -> ::connectrpc::dispatcher::codegen::StreamingResult {
2086                let Some(method) = path.strip_prefix(#path_prefix) else {
2087                    return ::connectrpc::dispatcher::codegen::unimplemented_streaming(path);
2088                };
2089                let _ = (&ctx, &requests, &format);
2090                match method {
2091                    #(#call_bidi_arms)*
2092                    _ => ::connectrpc::dispatcher::codegen::unimplemented_streaming(path),
2093                }
2094            }
2095        }
2096    })
2097}
2098
2099/// Generate documentation comment tokens.
2100fn generate_doc_comment(doc: &str, default: &str) -> TokenStream {
2101    let comment = if doc.is_empty() { default } else { doc };
2102    doc_attrs(comment)
2103}
2104
2105/// Generate a trait method for a service.
2106fn generate_trait_method(
2107    file: &FileDescriptorProto,
2108    service: &ServiceDescriptorProto,
2109    method: &MethodDescriptorProto,
2110    resolver: &TypeResolver<'_>,
2111    package: &str,
2112) -> Result<TokenStream> {
2113    let method_name = method.name.as_deref().unwrap_or("");
2114    let method_snake = make_field_ident(&method_name.to_snake_case());
2115    let output_type = resolver.rust_type(method.output_type.as_deref().unwrap_or(""), package)?;
2116
2117    // Get method documentation
2118    let method_doc = get_method_comment(file, service, method).unwrap_or_default();
2119    let method_doc_tokens =
2120        generate_doc_comment(&method_doc, &format!("Handle the {method_name} RPC."));
2121
2122    // Check for streaming
2123    let client_streaming = method.client_streaming.unwrap_or(false);
2124    let server_streaming = method.server_streaming.unwrap_or(false);
2125
2126    let borrow_doc = quote! {
2127        #[doc = ""]
2128        #[doc = " `'a` lets the response body borrow from `&self` (e.g. server-resident state)."]
2129    };
2130
2131    if server_streaming && !client_streaming {
2132        // Server streaming method. `impl Encodable<...>` lets the handler
2133        // yield `Res`, `PreEncoded`, or `MaybeBorrowed` items — same
2134        // flexibility as the unary `impl Encodable<...>` body bound.
2135        // `use<Self>` opts out of capturing `&self`'s lifetime (RPITITs in
2136        // trait methods otherwise capture it by default), since stream
2137        // items have to be `'static`. Without it, the generated route
2138        // registration's `Arc::clone` closures fail E0597. The borrowed
2139        // `ServiceRequest` lifetime is likewise excluded, so the returned
2140        // stream cannot borrow from the request — anything the stream needs
2141        // must be copied or converted to owned before returning it.
2142        let input_fqn = method.input_type.as_deref().unwrap_or("");
2143        let input_owned = resolver.rust_type(input_fqn, package)?;
2144        let request_param = quote! { ::connectrpc::ServiceRequest<'_, #input_owned> };
2145        let request_doc = quote! {
2146            #[doc = ""]
2147            #[doc = " `request` is borrowed from the request body and is valid for the"]
2148            #[doc = " duration of the call (until the response stream is returned);"]
2149            #[doc = " message fields are read directly on it (zero-copy). Data the"]
2150            #[doc = " returned stream needs must be copied out or converted via"]
2151            #[doc = " `.to_owned_message()`."]
2152        };
2153        Ok(quote! {
2154            #method_doc_tokens
2155            #request_doc
2156            fn #method_snake(
2157                &self,
2158                ctx: ::connectrpc::RequestContext,
2159                request: #request_param,
2160            ) -> impl ::std::future::Future<Output = ::connectrpc::ServiceResult<::connectrpc::ServiceStream<impl ::connectrpc::Encodable<#output_type> + Send + use<Self>>>> + Send;
2161        })
2162    } else if client_streaming && !server_streaming {
2163        // Client streaming method. Inbound items are `StreamMessage<Req>` —
2164        // each received message owns its decoded buffer (zero-copy reads via
2165        // `.view()`, conversion via `.to_owned_message()`, and — for
2166        // echo-shaped methods — items can be forwarded as-is since
2167        // `StreamMessage<M>: Encodable<M>`).
2168        let stream_owned = stream_owned_message_type(resolver, method, package)?;
2169        let items_doc = stream_items_doc(method);
2170        Ok(quote! {
2171            #method_doc_tokens
2172            #borrow_doc
2173            #items_doc
2174            fn #method_snake<'a>(
2175                &'a self,
2176                ctx: ::connectrpc::RequestContext,
2177                requests: ::connectrpc::InboundStream<#stream_owned>,
2178            ) -> impl ::std::future::Future<Output = ::connectrpc::ServiceResult<impl ::connectrpc::Encodable<#output_type> + Send + use<'a, Self>>> + Send;
2179        })
2180    } else if client_streaming && server_streaming {
2181        // Bidi streaming method. Same `impl Encodable<...>` item type and
2182        // `use<Self>` capture clause as server streaming above; inbound items
2183        // are `StreamMessage<Req>` as for client streaming.
2184        let stream_owned = stream_owned_message_type(resolver, method, package)?;
2185        let items_doc = stream_items_doc(method);
2186        Ok(quote! {
2187            #method_doc_tokens
2188            #items_doc
2189            fn #method_snake(
2190                &self,
2191                ctx: ::connectrpc::RequestContext,
2192                requests: ::connectrpc::InboundStream<#stream_owned>,
2193            ) -> impl ::std::future::Future<Output = ::connectrpc::ServiceResult<::connectrpc::ServiceStream<impl ::connectrpc::Encodable<#output_type> + Send + use<Self>>>> + Send;
2194        })
2195    } else {
2196        // Unary method. The request is *borrowed*: the generated dispatcher
2197        // owns the request body for the duration of the call and hands the
2198        // handler a `ServiceRequest<'_, Req>` (zero-copy view + raw body)
2199        // borrowed from it, so field access (`request.field`) is plain
2200        // borrow-checked access with no synthetic `'static` involved. The
2201        // handler future captures that borrow (RPITIT captures all in-scope
2202        // lifetimes), which is fine because the dispatcher awaits it while
2203        // still owning the body. The response's `use<'a, Self>` deliberately
2204        // excludes the request lifetime: the response must not borrow from
2205        // the request.
2206        let input_fqn = method.input_type.as_deref().unwrap_or("");
2207        let input_owned = resolver.rust_type(input_fqn, package)?;
2208        let request_param = quote! { ::connectrpc::ServiceRequest<'_, #input_owned> };
2209        let request_doc = quote! {
2210            #[doc = ""]
2211            #[doc = " `request` is borrowed from the request body and is valid for the"]
2212            #[doc = " duration of the call; message fields are read directly on it"]
2213            #[doc = " (zero-copy). The response cannot borrow from `request` — use"]
2214            #[doc = " `.to_owned_message()` (or copy the specific fields) for anything"]
2215            #[doc = " returned, stored, or moved into `tokio::spawn`."]
2216        };
2217        Ok(quote! {
2218            #method_doc_tokens
2219            #borrow_doc
2220            #request_doc
2221            fn #method_snake<'a>(
2222                &'a self,
2223                ctx: ::connectrpc::RequestContext,
2224                request: #request_param,
2225            ) -> impl ::std::future::Future<Output = ::connectrpc::ServiceResult<impl ::connectrpc::Encodable<#output_type> + Send + use<'a, Self>>> + Send;
2226        })
2227    }
2228}
2229
2230/// Generate client method(s) for a service RPC.
2231///
2232/// Emits two methods per RPC:
2233///   - `<method_snake>(&self, ...)` — no-options convenience, delegates to `_with_options`
2234///   - `<method_snake>_with_options(&self, ..., options: CallOptions)` — explicit options
2235///
2236/// This gives callers an ergonomic default while still surfacing per-call
2237/// control. The library's `effective_options()` merges options over
2238/// ClientConfig defaults, so the no-options variant still picks up any
2239/// client-wide defaults the user configured.
2240fn generate_client_method(
2241    service: &ServiceDescriptorProto,
2242    full_service_name: &str,
2243    method: &MethodDescriptorProto,
2244    resolver: &TypeResolver<'_>,
2245    package: &str,
2246) -> Result<TokenStream> {
2247    let method_name = method.name.as_deref().unwrap_or("");
2248    // The method's module-scope `*_SPEC` constant, passed to the runtime with
2249    // `origin` flipped to `Client` (see `generate_spec_consts`).
2250    let spec_const = method_spec_const_ident(service, method_name);
2251    let client_spec = quote! { #spec_const.with_origin(::connectrpc::SpecOrigin::Client) };
2252    let method_snake = make_field_ident(&method_name.to_snake_case());
2253    let method_with_opts = format_ident!("{}_with_options", method_name.to_snake_case());
2254    let input_type = resolver.rust_type(method.input_type.as_deref().unwrap_or(""), package)?;
2255    let output_view_type =
2256        resolver.rust_view_type(method.output_type.as_deref().unwrap_or(""), package)?;
2257
2258    let client_streaming = method.client_streaming.unwrap_or(false);
2259    let server_streaming = method.server_streaming.unwrap_or(false);
2260
2261    let doc = format!(
2262        " Call the {method_name} RPC. Sends a request to /{full_service_name}/{method_name}."
2263    );
2264    let doc_opts = format!(
2265        " Call the {method_name} RPC with explicit per-call options. \
2266         Options override [`ClientConfig`](::connectrpc::client::ClientConfig) defaults."
2267    );
2268
2269    // Return type is protocol-specific. Compute once.
2270    let ret_ty: TokenStream;
2271    let call_body: TokenStream;
2272    let short_args: TokenStream; // args to the no-opts convenience method
2273    let opts_args: TokenStream; // args to the _with_options method
2274    let short_delegate_args: TokenStream; // how short delegates to opts
2275    // Extra doc lines appended to both methods (client-stream input contract).
2276    let mut extra_doc = quote! {};
2277
2278    if client_streaming && !server_streaming {
2279        // Client-stream
2280        extra_doc = quote! {
2281            #[doc = ""]
2282            #[doc = " `requests` is any `Stream<Item = ...> + Send + 'static` of"]
2283            #[doc = " request messages (the `ClientRequestStream` bound); messages"]
2284            #[doc = " are sent as the stream yields them. It backs the request"]
2285            #[doc = " body, so yield owned messages or feed the call from a"]
2286            #[doc = " channel-backed stream. For a collection that is already in"]
2287            #[doc = " hand, wrap it with `::connectrpc::stream_iter(...)`."]
2288            #[doc = ""]
2289            #[doc = " Dropping the returned future cancels the call: the request"]
2290            #[doc = " body is dropped along with it, so messages the stream had"]
2291            #[doc = " not yet yielded are never delivered. A caller that needs the"]
2292            #[doc = " request delivered must drive the call to completion rather"]
2293            #[doc = " than, say, wrapping it in a `timeout`."]
2294        };
2295        ret_ty = quote! {
2296            Result<
2297                ::connectrpc::client::UnaryResponse<::buffa::view::OwnedView<#output_view_type<'static>>>,
2298                ::connectrpc::ConnectError,
2299            >
2300        };
2301        call_body = quote! {
2302            ::connectrpc::client::call_client_stream(
2303                &self.transport, &self.config,
2304                #client_spec,
2305                requests, options,
2306            ).await
2307        };
2308        short_args =
2309            quote! { requests: impl ::connectrpc::client::ClientRequestStream<#input_type> };
2310        opts_args = quote! { requests: impl ::connectrpc::client::ClientRequestStream<#input_type>, options: ::connectrpc::client::CallOptions };
2311        short_delegate_args = quote! { requests, ::connectrpc::client::CallOptions::default() };
2312    } else if client_streaming && server_streaming {
2313        // Bidi
2314        ret_ty = quote! {
2315            Result<
2316                ::connectrpc::client::BidiStream<
2317                    T::ResponseBody, #input_type, #output_view_type<'static>
2318                >,
2319                ::connectrpc::ConnectError,
2320            >
2321        };
2322        call_body = quote! {
2323            ::connectrpc::client::call_bidi_stream(
2324                &self.transport, &self.config,
2325                #client_spec, options,
2326            ).await
2327        };
2328        short_args = quote! {};
2329        opts_args = quote! { options: ::connectrpc::client::CallOptions };
2330        short_delegate_args = quote! { ::connectrpc::client::CallOptions::default() };
2331    } else if server_streaming {
2332        // Server-stream
2333        ret_ty = quote! {
2334            Result<
2335                ::connectrpc::client::ServerStream<T::ResponseBody, #output_view_type<'static>>,
2336                ::connectrpc::ConnectError,
2337            >
2338        };
2339        call_body = quote! {
2340            ::connectrpc::client::call_server_stream(
2341                &self.transport, &self.config,
2342                #client_spec,
2343                request, options,
2344            ).await
2345        };
2346        short_args = quote! { request: #input_type };
2347        opts_args = quote! { request: #input_type, options: ::connectrpc::client::CallOptions };
2348        short_delegate_args = quote! { request, ::connectrpc::client::CallOptions::default() };
2349    } else {
2350        // Unary
2351        ret_ty = quote! {
2352            Result<
2353                ::connectrpc::client::UnaryResponse<::buffa::view::OwnedView<#output_view_type<'static>>>,
2354                ::connectrpc::ConnectError,
2355            >
2356        };
2357        call_body = quote! {
2358            ::connectrpc::client::call_unary(
2359                &self.transport, &self.config,
2360                #client_spec,
2361                request, options,
2362            ).await
2363        };
2364        short_args = quote! { request: #input_type };
2365        opts_args = quote! { request: #input_type, options: ::connectrpc::client::CallOptions };
2366        short_delegate_args = quote! { request, ::connectrpc::client::CallOptions::default() };
2367    }
2368
2369    Ok(quote! {
2370        #[doc = #doc]
2371        #extra_doc
2372        pub async fn #method_snake(&self, #short_args) -> #ret_ty {
2373            self.#method_with_opts(#short_delegate_args).await
2374        }
2375
2376        #[doc = #doc_opts]
2377        #extra_doc
2378        pub async fn #method_with_opts(&self, #opts_args) -> #ret_ty {
2379            #call_body
2380        }
2381    })
2382}
2383
2384/// Get the documentation comment for a service.
2385fn get_service_comment(
2386    file: &FileDescriptorProto,
2387    service: &ServiceDescriptorProto,
2388) -> Option<String> {
2389    // MessageField derefs to default when unset; default has empty location vec
2390    let source_info: &SourceCodeInfo = &file.source_code_info;
2391
2392    // Find service index
2393    let service_index = file.service.iter().position(|s| s.name == service.name)?;
2394
2395    // Path for service: [6, service_index]
2396    // 6 = service field number in FileDescriptorProto
2397    let target_path = vec![6, service_index as i32];
2398
2399    find_comment(source_info, &target_path)
2400}
2401
2402/// Get the documentation comment for a method.
2403fn get_method_comment(
2404    file: &FileDescriptorProto,
2405    service: &ServiceDescriptorProto,
2406    method: &MethodDescriptorProto,
2407) -> Option<String> {
2408    let source_info: &SourceCodeInfo = &file.source_code_info;
2409
2410    // Find service and method indices, matching on the parent service name
2411    // to avoid ambiguity when multiple services have methods with the same name.
2412    let (service_index, method_index) = file.service.iter().enumerate().find_map(|(si, s)| {
2413        if s.name != service.name {
2414            return None;
2415        }
2416        s.method
2417            .iter()
2418            .position(|m| m.name == method.name)
2419            .map(|mi| (si, mi))
2420    })?;
2421
2422    // Path for method: [6, service_index, 2, method_index]
2423    // 6 = service field number in FileDescriptorProto
2424    // 2 = method field number in ServiceDescriptorProto
2425    let target_path = vec![6, service_index as i32, 2, method_index as i32];
2426
2427    find_comment(source_info, &target_path)
2428}
2429
2430/// Find a comment in source code info for the given path.
2431fn find_comment(source_info: &SourceCodeInfo, target_path: &[i32]) -> Option<String> {
2432    for location in &source_info.location {
2433        if location.path == target_path {
2434            let comment = location
2435                .leading_comments
2436                .as_ref()
2437                .or(location.trailing_comments.as_ref())?;
2438
2439            // protoc strips the `//` marker but keeps the space that follows
2440            // it; drop that one space per line while preserving deeper
2441            // indentation (code blocks) and blank lines (paragraph breaks).
2442            // `doc_attrs` adds its own uniform leading space for
2443            // prettyplease rendering.
2444            let normalized: String = comment
2445                .lines()
2446                .map(|line| line.strip_prefix(' ').unwrap_or(line).trim_end())
2447                .collect::<Vec<_>>()
2448                .join("\n");
2449
2450            // Escape markdown/HTML metacharacters so arbitrary proto
2451            // comments can't break the consumer's rustdoc build.
2452            let cleaned = crate::comments::sanitize_comment(normalized.trim_matches('\n'));
2453
2454            if !cleaned.is_empty() {
2455                return Some(cleaned);
2456            }
2457        }
2458    }
2459    None
2460}
2461
2462#[cfg(test)]
2463mod tests {
2464    use super::*;
2465    use buffa_codegen::generated::descriptor::DescriptorProto;
2466    use quote::ToTokens;
2467
2468    #[test]
2469    fn doc_attrs_prefixes_space_for_prettyplease() {
2470        // prettyplease emits `#[doc = "X"]` as `///X` verbatim. We prefix
2471        // each non-blank line with a space so the output is `/// X`.
2472        let ts = quote! {
2473            #[allow(dead_code)]
2474            mod m {}
2475        };
2476        let doc = doc_attrs("Hello.\n\nSecond paragraph.");
2477        let combined = quote! { #doc #ts };
2478        let file = syn::parse2::<syn::File>(combined).unwrap();
2479        let out = prettyplease::unparse(&file);
2480        // Each non-blank line should have a space after ///.
2481        assert!(out.contains("/// Hello."), "got: {out}");
2482        assert!(out.contains("/// Second paragraph."), "got: {out}");
2483        // Blank line becomes bare /// (paragraph break).
2484        assert!(out.contains("///\n"), "got: {out}");
2485        // Should NOT contain ///H (no space) or ///  H (double space).
2486        assert!(!out.contains("///Hello"), "got: {out}");
2487        assert!(!out.contains("///  Hello"), "got: {out}");
2488    }
2489
2490    /// Build a minimal proto file with one message type and one service method.
2491    /// The service method's input/output types are fully-qualified proto names
2492    /// (e.g. `.example.v1.PingReq` or `.google.protobuf.Empty`) so the resolver
2493    /// can look them up.
2494    fn minimal_file(
2495        package: Option<&str>,
2496        input_type: &str,
2497        output_type: &str,
2498        local_messages: &[&str],
2499    ) -> FileDescriptorProto {
2500        minimal_file_with_method(package, "Ping", input_type, output_type, local_messages)
2501    }
2502
2503    /// Like [`minimal_file`] but with a custom RPC method name, for testing
2504    /// keyword collisions and other name-derived behaviour.
2505    fn minimal_file_with_method(
2506        package: Option<&str>,
2507        method_name: &str,
2508        input_type: &str,
2509        output_type: &str,
2510        local_messages: &[&str],
2511    ) -> FileDescriptorProto {
2512        let method = MethodDescriptorProto {
2513            name: Some(method_name.into()),
2514            input_type: Some(input_type.into()),
2515            output_type: Some(output_type.into()),
2516            ..Default::default()
2517        };
2518        let service = ServiceDescriptorProto {
2519            name: Some("PingService".into()),
2520            method: vec![method],
2521            ..Default::default()
2522        };
2523        FileDescriptorProto {
2524            name: Some("ping.proto".into()),
2525            package: package.map(|p| p.into()),
2526            service: vec![service],
2527            message_type: local_messages
2528                .iter()
2529                .map(|name| DescriptorProto {
2530                    name: Some((*name).into()),
2531                    ..Default::default()
2532                })
2533                .collect(),
2534            ..Default::default()
2535        }
2536    }
2537
2538    /// Build a minimal proto file with one service holding the given method
2539    /// names, all typed `Empty` -> `Empty`. Used for collision tests where
2540    /// the method *names* are what's under test.
2541    fn minimal_file_with_methods(package: &str, method_names: &[&str]) -> FileDescriptorProto {
2542        let methods = method_names
2543            .iter()
2544            .map(|n| MethodDescriptorProto {
2545                name: Some((*n).into()),
2546                input_type: Some(format!(".{package}.Empty")),
2547                output_type: Some(format!(".{package}.Empty")),
2548                ..Default::default()
2549            })
2550            .collect();
2551        let service = ServiceDescriptorProto {
2552            name: Some("PingService".into()),
2553            method: methods,
2554            ..Default::default()
2555        };
2556        FileDescriptorProto {
2557            name: Some("ping.proto".into()),
2558            package: Some(package.into()),
2559            service: vec![service],
2560            message_type: vec![DescriptorProto {
2561                name: Some("Empty".into()),
2562                ..Default::default()
2563            }],
2564            ..Default::default()
2565        }
2566    }
2567
2568    /// Generate service code for `files[target_idx]`. All files are visible
2569    /// to the resolver (as transitive deps via `--include_imports`), but
2570    /// only the target is in `file_to_generate` — mirroring real protoc use.
2571    ///
2572    /// `extern_paths` is wired into `CodeGenConfig.extern_paths` (which
2573    /// feeds the resolver's type_map via `effective_extern_paths`).
2574    /// `require_extern` selects unified (`false`, super::-relative) vs
2575    /// split (`true`, absolute-only) mode.
2576    fn gen_service(
2577        files: &[FileDescriptorProto],
2578        target_idx: usize,
2579        extern_paths: &[(String, String)],
2580        require_extern: bool,
2581    ) -> Result<String> {
2582        let mut config = buffa_codegen::CodeGenConfig::default();
2583        config.extern_paths = extern_paths.to_vec();
2584        let target_name = files[target_idx]
2585            .name
2586            .clone()
2587            .into_iter()
2588            .collect::<Vec<_>>();
2589        let resolver = TypeResolver::new(files, &target_name, &config, require_extern);
2590        let file = &files[target_idx];
2591        let service = &file.service[0];
2592        let batch = BatchState {
2593            colliding_aliases: collect_alias_collisions(files, &target_name),
2594            ..BatchState::default()
2595        };
2596        Ok(generate_service(file, service, &resolver, &batch)?.to_string())
2597    }
2598
2599    /// Assert that `formatted` (a Rust source string) contains no `use`
2600    /// items at the file root. Parses with `syn` rather than string-matching
2601    /// so doc comments, string literals, and indented `use` statements in
2602    /// nested modules cannot trigger false positives.
2603    fn assert_no_top_level_use(formatted: &str, label: &str) {
2604        let parsed: syn::File = syn::parse_str(formatted).expect("formatted code parses");
2605        let offenders: Vec<String> = parsed
2606            .items
2607            .iter()
2608            .filter_map(|item| match item {
2609                syn::Item::Use(u) => Some(quote!(#u).to_string()),
2610                _ => None,
2611            })
2612            .collect();
2613        assert!(
2614            offenders.is_empty(),
2615            "{label} contains top-level use statement(s): {offenders:?}\nFull source:\n{formatted}"
2616        );
2617    }
2618
2619    fn gen_file(
2620        files: &[FileDescriptorProto],
2621        target_idx: usize,
2622        extern_paths: &[(String, String)],
2623        require_extern: bool,
2624    ) -> Result<String> {
2625        let mut config = buffa_codegen::CodeGenConfig::default();
2626        config.extern_paths = extern_paths.to_vec();
2627        let target_name = files[target_idx]
2628            .name
2629            .clone()
2630            .into_iter()
2631            .collect::<Vec<_>>();
2632        let resolver = TypeResolver::new(files, &target_name, &config, require_extern);
2633        let mut batch = BatchState {
2634            colliding_aliases: collect_alias_collisions(files, &target_name),
2635            ..BatchState::default()
2636        };
2637        Ok(generate_connect_services(&files[target_idx], &resolver, &mut batch)?.to_string())
2638    }
2639
2640    #[test]
2641    fn unary_response_body_captures_self_lifetime() {
2642        let file = minimal_file(
2643            Some("example.v1"),
2644            ".example.v1.PingReq",
2645            ".example.v1.PingResp",
2646            &["PingReq", "PingResp"],
2647        );
2648        let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
2649        assert!(code.contains("< 'a >"), "trait method missing 'a: {code}");
2650        assert!(code.contains("& 'a self"), "missing &'a self: {code}");
2651        assert!(
2652            code.contains("use < 'a , Self >"),
2653            "missing use<'a, Self> capture: {code}"
2654        );
2655        assert!(
2656            !code.contains("'static + use"),
2657            "'static bound on body should be dropped: {code}"
2658        );
2659    }
2660
2661    #[test]
2662    fn owned_view_aliases_emitted_for_input_and_output() {
2663        let file = minimal_file(
2664            Some("example.v1"),
2665            ".example.v1.PingReq",
2666            ".example.v1.PingResp",
2667            &["PingReq", "PingResp"],
2668        );
2669        let code = gen_file(std::slice::from_ref(&file), 0, &[], false).unwrap();
2670        assert!(
2671            code.contains("pub type OwnedPingReqView = :: buffa :: view :: OwnedView"),
2672            "missing OwnedPingReqView alias: {code}"
2673        );
2674        assert!(
2675            code.contains("pub type OwnedPingRespView = :: buffa :: view :: OwnedView"),
2676            "missing OwnedPingRespView alias: {code}"
2677        );
2678        // Unary trait methods take a borrowed ServiceRequest; the alias is
2679        // still emitted (the natural spelling for pass-through response
2680        // bodies, e.g. `MaybeBorrowed<Ping, OwnedPingView>` holding a
2681        // `req.to_owned_view()`).
2682        assert!(
2683            code.contains("request : :: connectrpc :: ServiceRequest < '_"),
2684            "unary trait method should take request: ServiceRequest<'_, PingReq>: {code}"
2685        );
2686        // The view-family impls backing ServiceRequest come from buffa's own
2687        // codegen (alongside each message's view types), so connect-codegen
2688        // emits none of its own.
2689        assert!(
2690            !code.contains("impl :: connectrpc :: HasMessageView for"),
2691            "connect-codegen must not emit view-family impls (buffa does): {code}"
2692        );
2693    }
2694
2695    #[test]
2696    fn cross_package_input_collision_suppresses_alias_for_both_sides() {
2697        // Regression test for #75. A service file that defines its own
2698        // `MyMessage` and also uses an imported `.api.v1.foo.bar.MyMessage`
2699        // as an RPC input previously emitted `pub type OwnedMyMessageView`
2700        // twice (once for the local output, once for the cross-package
2701        // input), failing to compile with E0428. The fix detects the
2702        // colliding alias name and inlines the `OwnedView<…<'static>>`
2703        // form for both members of the colliding set.
2704        let v1 = FileDescriptorProto {
2705            name: Some("api/v1/foo/bar/foobar.proto".into()),
2706            package: Some("api.v1.foo.bar".into()),
2707            message_type: vec![DescriptorProto {
2708                name: Some("MyMessage".into()),
2709                ..Default::default()
2710            }],
2711            ..Default::default()
2712        };
2713        let v2 = minimal_file(
2714            Some("api.v2.foo.bar"),
2715            ".api.v1.foo.bar.MyMessage",
2716            ".api.v2.foo.bar.MyMessage",
2717            &["MyMessage"],
2718        );
2719        let code = gen_file(&[v1, v2], 1, &[], false).unwrap();
2720
2721        // Neither side gets an alias because both would land at the same
2722        // identifier in the same module.
2723        let alias_count = code.matches("pub type OwnedMyMessageView").count();
2724        assert_eq!(
2725            alias_count, 0,
2726            "expected zero OwnedMyMessageView aliases when both sides collide; got {alias_count}: {code}"
2727        );
2728
2729        // Both colliding sides reach the trait sig as the inlined
2730        // `OwnedView<…<'static>>` form.
2731        assert!(
2732            !code.contains("request : OwnedMyMessageView"),
2733            "colliding input must not reference the suppressed alias: {code}"
2734        );
2735        // The unary request is a borrowed ServiceRequest over the owned type,
2736        // so the alias collision only affects the (still-suppressed) aliases.
2737        assert!(
2738            code.contains("request : :: connectrpc :: ServiceRequest < '_"),
2739            "colliding unary input should still use ServiceRequest: {code}"
2740        );
2741    }
2742
2743    #[test]
2744    fn cross_package_input_without_collision_keeps_alias() {
2745        // The #75 fix only suppresses aliases when two distinct FQNs in
2746        // the same target package would produce the same alias name. A
2747        // cross-package input with a unique short name (e.g. WKT inputs
2748        // like `.google.protobuf.Empty`) keeps its `OwnedEmptyView`
2749        // alias — generated handler code that previously read
2750        // `request: OwnedEmptyView` keeps working.
2751        let wkt = FileDescriptorProto {
2752            name: Some("google/protobuf/empty.proto".into()),
2753            package: Some("google.protobuf".into()),
2754            message_type: vec![DescriptorProto {
2755                name: Some("Empty".into()),
2756                ..Default::default()
2757            }],
2758            ..Default::default()
2759        };
2760        let svc = minimal_file(
2761            Some("example.v1"),
2762            ".google.protobuf.Empty",
2763            ".example.v1.PingResp",
2764            &["PingResp"],
2765        );
2766        let code = gen_file(&[wkt, svc], 1, &[], false).unwrap();
2767        assert!(
2768            code.contains("pub type OwnedEmptyView = :: buffa :: view :: OwnedView"),
2769            "WKT cross-package input should keep its alias: {code}"
2770        );
2771        // `.google.protobuf.Empty` resolves through the default extern_path to
2772        // `::buffa_types::…`. extern_path targets are required to be
2773        // buffa ≥ 0.9.0 generated code with views enabled, so the unary input
2774        // uses the same `ServiceRequest<'_, Req>` form as local types — the
2775        // backing `buffa::HasMessageView` impl ships with buffa-types.
2776        assert!(
2777            code.contains(
2778                "request : :: connectrpc :: ServiceRequest < '_ , :: buffa_types :: google :: protobuf :: Empty >"
2779            ),
2780            "extern unary input should use ServiceRequest over the extern owned type: {code}"
2781        );
2782    }
2783
2784    #[test]
2785    fn collision_inlines_in_all_streaming_method_shapes() {
2786        // The #75 fix substitutes `#input_arg` at four interpolation
2787        // sites in `generate_trait_method` (server-streaming, client-
2788        // streaming, bidi, unary). This drives all four shapes through
2789        // a colliding cross-package input to catch any regression that
2790        // accidentally drops the substitution from one branch.
2791        let v1 = FileDescriptorProto {
2792            name: Some("api/v1/foo/bar/foobar.proto".into()),
2793            package: Some("api.v1.foo.bar".into()),
2794            message_type: vec![DescriptorProto {
2795                name: Some("MyMessage".into()),
2796                ..Default::default()
2797            }],
2798            ..Default::default()
2799        };
2800        let v2 = FileDescriptorProto {
2801            name: Some("api/v2/foo/bar/foobar.proto".into()),
2802            package: Some("api.v2.foo.bar".into()),
2803            message_type: vec![DescriptorProto {
2804                name: Some("MyMessage".into()),
2805                ..Default::default()
2806            }],
2807            service: vec![ServiceDescriptorProto {
2808                name: Some("FooBar".into()),
2809                method: vec![
2810                    MethodDescriptorProto {
2811                        name: Some("Unary".into()),
2812                        input_type: Some(".api.v1.foo.bar.MyMessage".into()),
2813                        output_type: Some(".api.v2.foo.bar.MyMessage".into()),
2814                        ..Default::default()
2815                    },
2816                    MethodDescriptorProto {
2817                        name: Some("ServerStream".into()),
2818                        input_type: Some(".api.v1.foo.bar.MyMessage".into()),
2819                        output_type: Some(".api.v2.foo.bar.MyMessage".into()),
2820                        server_streaming: Some(true),
2821                        ..Default::default()
2822                    },
2823                    MethodDescriptorProto {
2824                        name: Some("ClientStream".into()),
2825                        input_type: Some(".api.v1.foo.bar.MyMessage".into()),
2826                        output_type: Some(".api.v2.foo.bar.MyMessage".into()),
2827                        client_streaming: Some(true),
2828                        ..Default::default()
2829                    },
2830                    MethodDescriptorProto {
2831                        name: Some("Bidi".into()),
2832                        input_type: Some(".api.v1.foo.bar.MyMessage".into()),
2833                        output_type: Some(".api.v2.foo.bar.MyMessage".into()),
2834                        client_streaming: Some(true),
2835                        server_streaming: Some(true),
2836                        ..Default::default()
2837                    },
2838                ],
2839                ..Default::default()
2840            }],
2841            ..Default::default()
2842        };
2843        let code = gen_file(&[v1, v2], 1, &[], false).unwrap();
2844
2845        // None of the four method shapes reference the suppressed alias.
2846        assert!(
2847            !code.contains("OwnedMyMessageView"),
2848            "no method shape should reference the suppressed alias: {code}"
2849        );
2850
2851        // Unary and server-streaming both take the borrowed ServiceRequest
2852        // keyed by the owned message; the alias collision is irrelevant to it.
2853        assert!(
2854            code.matches("request : :: connectrpc :: ServiceRequest < '_")
2855                .count()
2856                >= 2,
2857            "unary and server-streaming should take the borrowed ServiceRequest form: {code}"
2858        );
2859        // Client-streaming and bidi inbound items are InboundStream<Req> keyed
2860        // by the owned message — the alias collision is irrelevant to them.
2861        assert!(
2862            code.matches("requests : :: connectrpc :: InboundStream <")
2863                .count()
2864                >= 2,
2865            "client-streaming and bidi should both take InboundStream items: {code}"
2866        );
2867    }
2868
2869    #[test]
2870    fn streaming_methods_use_encodable_item_type() {
2871        // Server-streaming and bidi methods should declare their stream
2872        // item type as `impl Encodable<Out> + Send + use<Self>` rather than
2873        // the bare `Out`, so handlers can return `PreEncoded` /
2874        // `MaybeBorrowed` items. The dispatcher and route-registration
2875        // arms must both turbofish `Res` since `Encodable<M>` for
2876        // `PreEncoded` is generic over `M` (so `Res` is no longer
2877        // derivable from the opaque item type).
2878        let file = FileDescriptorProto {
2879            name: Some("ex/v1/svc.proto".into()),
2880            package: Some("ex.v1".into()),
2881            message_type: vec![
2882                DescriptorProto {
2883                    name: Some("Req".into()),
2884                    ..Default::default()
2885                },
2886                DescriptorProto {
2887                    name: Some("Resp".into()),
2888                    ..Default::default()
2889                },
2890            ],
2891            service: vec![ServiceDescriptorProto {
2892                name: Some("Svc".into()),
2893                method: vec![
2894                    MethodDescriptorProto {
2895                        name: Some("ServerStream".into()),
2896                        input_type: Some(".ex.v1.Req".into()),
2897                        output_type: Some(".ex.v1.Resp".into()),
2898                        server_streaming: Some(true),
2899                        ..Default::default()
2900                    },
2901                    MethodDescriptorProto {
2902                        name: Some("Bidi".into()),
2903                        input_type: Some(".ex.v1.Req".into()),
2904                        output_type: Some(".ex.v1.Resp".into()),
2905                        client_streaming: Some(true),
2906                        server_streaming: Some(true),
2907                        ..Default::default()
2908                    },
2909                ],
2910                ..Default::default()
2911            }],
2912            ..Default::default()
2913        };
2914        let code = gen_file(std::slice::from_ref(&file), 0, &[], false).unwrap();
2915
2916        // Trait method declares `ServiceStream<impl Encodable<Resp> + ...>`.
2917        assert_eq!(
2918            code.matches(":: connectrpc :: ServiceStream < impl :: connectrpc :: Encodable < Resp > + Send + use < Self >>")
2919                .count(),
2920            2,
2921            "server-streaming and bidi should both use the Encodable item type: {code}"
2922        );
2923
2924        // Dispatcher arms turbofish `Res` to encode_response_stream.
2925        assert_eq!(
2926            code.matches("encode_response_stream :: < Resp , _ , _ >")
2927                .count(),
2928            2,
2929            "dispatcher arms must turbofish Res to encode_response_stream: {code}"
2930        );
2931
2932        // Route registrations turbofish `Res` to route_view_*_stream.
2933        assert!(
2934            code.contains("route_view_server_stream :: < _ , _ , Resp >"),
2935            "route_view_server_stream must turbofish Res: {code}"
2936        );
2937        assert!(
2938            code.contains("route_view_bidi_stream :: < _ , _ , Resp >"),
2939            "route_view_bidi_stream must turbofish Res: {code}"
2940        );
2941    }
2942
2943    #[test]
2944    fn encodable_view_impls_emitted_per_output_type() {
2945        let file = minimal_file(
2946            Some("example.v1"),
2947            ".example.v1.PingReq",
2948            ".example.v1.PingResp",
2949            &["PingReq", "PingResp"],
2950        );
2951        let code = gen_file(std::slice::from_ref(&file), 0, &[], false).unwrap();
2952        assert!(
2953            code.contains(
2954                ":: connectrpc :: Encodable < PingResp > for __buffa :: view :: PingRespView"
2955            ),
2956            "missing Encodable<PingResp> for PingRespView: {code}"
2957        );
2958        assert!(
2959            code.contains(
2960                ":: connectrpc :: Encodable < PingResp > for :: buffa :: view :: OwnedView"
2961            ),
2962            "missing Encodable<PingResp> for OwnedView<PingRespView>: {code}"
2963        );
2964        // Input type should NOT get an impl (only output types).
2965        assert!(!code.contains("Encodable < PingReq >"), "got: {code}");
2966    }
2967
2968    #[test]
2969    fn encodable_view_impls_skipped_for_extern_output() {
2970        // Output type resolves via the WKT extern_path → ::buffa_types::...
2971        // so the impl would be an orphan; verify it's skipped.
2972        let wkt = FileDescriptorProto {
2973            name: Some("google/protobuf/empty.proto".into()),
2974            package: Some("google.protobuf".into()),
2975            message_type: vec![DescriptorProto {
2976                name: Some("Empty".into()),
2977                ..Default::default()
2978            }],
2979            ..Default::default()
2980        };
2981        let file = minimal_file(
2982            Some("example.v1"),
2983            ".example.v1.PingReq",
2984            ".google.protobuf.Empty",
2985            &["PingReq"],
2986        );
2987        let code = gen_file(&[wkt, file], 1, &[], false).unwrap();
2988        // The impl bodies call encode_view_body; the trait method's
2989        // `impl Encodable<M>` RPITIT bound doesn't.
2990        assert!(
2991            !code.contains("encode_view_body"),
2992            "extern output type must not get Encodable impl: {code}"
2993        );
2994    }
2995
2996    #[test]
2997    fn encodable_view_impls_deduped_across_files() {
2998        // Two service files in different packages both return
2999        // `.common.v1.Reply`. The stitcher mounts both files into one
3000        // module tree, so the Encodable<Reply> impls must be emitted
3001        // exactly once across the batch (else E0119).
3002        let common = FileDescriptorProto {
3003            name: Some("common.proto".into()),
3004            package: Some("common.v1".into()),
3005            message_type: vec![DescriptorProto {
3006                name: Some("Reply".into()),
3007                ..Default::default()
3008            }],
3009            ..Default::default()
3010        };
3011        let svc = |name: &str, pkg: &str| FileDescriptorProto {
3012            name: Some(name.into()),
3013            package: Some(pkg.into()),
3014            message_type: vec![DescriptorProto {
3015                name: Some("Req".into()),
3016                ..Default::default()
3017            }],
3018            service: vec![ServiceDescriptorProto {
3019                name: Some("S".into()),
3020                method: vec![MethodDescriptorProto {
3021                    name: Some("Call".into()),
3022                    input_type: Some(format!(".{pkg}.Req")),
3023                    output_type: Some(".common.v1.Reply".into()),
3024                    ..Default::default()
3025                }],
3026                ..Default::default()
3027            }],
3028            ..Default::default()
3029        };
3030        let files = vec![common, svc("a.proto", "a.v1"), svc("b.proto", "b.v1")];
3031
3032        let generated = generate_files(
3033            &files,
3034            &["a.proto".into(), "b.proto".into()],
3035            &Options::default(),
3036        )
3037        .unwrap();
3038
3039        // Each service-declaring proto produces exactly one Companion file
3040        // named `<stem>.__connect.rs`, wired into its package stitcher.
3041        let companions: Vec<_> = generated
3042            .iter()
3043            .filter(|f| f.kind == GeneratedFileKind::Companion)
3044            .collect();
3045        let mut companion_names: Vec<&str> = companions.iter().map(|f| f.name.as_str()).collect();
3046        companion_names.sort_unstable();
3047        assert_eq!(companion_names, ["a.__connect.rs", "b.__connect.rs"]);
3048        for c in &companions {
3049            let stitcher = generated
3050                .iter()
3051                .find(|g| g.kind == GeneratedFileKind::PackageMod && g.package == c.package)
3052                .expect("each companion's package must have a stitcher");
3053            assert!(
3054                stitcher
3055                    .content
3056                    .contains(&format!("include!(\"{}\")", c.name)),
3057                "stitcher for {} must include companion {}",
3058                c.package,
3059                c.name
3060            );
3061        }
3062
3063        let combined: String = companions.iter().map(|f| f.content.as_str()).collect();
3064
3065        let view_impl = "impl ::connectrpc::Encodable<super::super::common::v1::Reply>\nfor super::super::common::v1::__buffa::view::ReplyView<'_>";
3066        let owned_view_impl = "impl ::connectrpc::Encodable<super::super::common::v1::Reply>\nfor ::buffa::view::OwnedView<";
3067        assert_eq!(
3068            combined.matches(view_impl).count(),
3069            1,
3070            "Encodable<Reply> for ReplyView<'_> must appear once: {combined}"
3071        );
3072        assert_eq!(
3073            combined.matches(owned_view_impl).count(),
3074            1,
3075            "Encodable<Reply> for OwnedView<ReplyView> must appear once: {combined}"
3076        );
3077    }
3078
3079    /// Two service-declaring protos in the same package, plus one in a
3080    /// second package, with a shared dependency proto. Used by the
3081    /// `file_per_package` tests to exercise cross-file inlining and
3082    /// per-package grouping together.
3083    fn file_per_package_fixture() -> Vec<FileDescriptorProto> {
3084        let common = FileDescriptorProto {
3085            name: Some("common.proto".into()),
3086            package: Some("common.v1".into()),
3087            message_type: vec![DescriptorProto {
3088                name: Some("Reply".into()),
3089                ..Default::default()
3090            }],
3091            ..Default::default()
3092        };
3093        // Each service file declares its own request message — proto packages
3094        // can't have duplicate FQNs, so two same-package files with the same
3095        // message name would be an invalid descriptor set (and inlining both
3096        // into one `<dotted.pkg>.rs` under file_per_package would E0428).
3097        let svc = |proto_name: &str, pkg: &str, svc_name: &str, req: &str| FileDescriptorProto {
3098            name: Some(proto_name.into()),
3099            package: Some(pkg.into()),
3100            message_type: vec![DescriptorProto {
3101                name: Some(req.into()),
3102                ..Default::default()
3103            }],
3104            service: vec![ServiceDescriptorProto {
3105                name: Some(svc_name.into()),
3106                method: vec![MethodDescriptorProto {
3107                    name: Some("Call".into()),
3108                    input_type: Some(format!(".{pkg}.{req}")),
3109                    output_type: Some(".common.v1.Reply".into()),
3110                    ..Default::default()
3111                }],
3112                ..Default::default()
3113            }],
3114            ..Default::default()
3115        };
3116        vec![
3117            common,
3118            svc("a/x.proto", "a.v1", "XService", "XReq"),
3119            svc("a/y.proto", "a.v1", "YService", "YReq"),
3120            svc("b/z.proto", "b.v1", "ZService", "ZReq"),
3121        ]
3122    }
3123
3124    #[test]
3125    fn generate_files_file_per_package_inlines_companions() {
3126        let files = file_per_package_fixture();
3127        let mut options = Options::default();
3128        options.buffa.file_per_package = true;
3129
3130        let generated = generate_files(
3131            &files,
3132            &["a/x.proto".into(), "a/y.proto".into(), "b/z.proto".into()],
3133            &options,
3134        )
3135        .unwrap();
3136
3137        // No Companion files survive — service stubs are inlined.
3138        assert!(
3139            !generated
3140                .iter()
3141                .any(|f| f.kind == GeneratedFileKind::Companion),
3142            "file_per_package must not emit sibling Companion files"
3143        );
3144        assert!(
3145            !generated.iter().any(|f| f.name.ends_with(".__connect.rs")),
3146            "file_per_package must not emit `<stem>.__connect.rs` files"
3147        );
3148
3149        // Each service-declaring package's PackageMod inlines its services.
3150        let a = generated
3151            .iter()
3152            .find(|f| f.kind == GeneratedFileKind::PackageMod && f.package == "a.v1")
3153            .expect("a.v1 PackageMod must exist");
3154        assert!(
3155            a.content.contains("pub trait XService"),
3156            "a.v1 missing XService"
3157        );
3158        assert!(
3159            a.content.contains("pub trait YService"),
3160            "a.v1 missing YService"
3161        );
3162        assert!(
3163            !a.content.contains("pub trait ZService"),
3164            "a.v1 must not inline ZService"
3165        );
3166        assert!(
3167            !a.content.contains("__connect.rs"),
3168            "a.v1 PackageMod must not include! a connect file: {}",
3169            a.content
3170        );
3171
3172        let b = generated
3173            .iter()
3174            .find(|f| f.kind == GeneratedFileKind::PackageMod && f.package == "b.v1")
3175            .expect("b.v1 PackageMod must exist");
3176        assert!(
3177            b.content.contains("pub trait ZService"),
3178            "b.v1 missing ZService"
3179        );
3180        assert!(
3181            !b.content.contains("pub trait XService"),
3182            "b.v1 must not inline XService"
3183        );
3184
3185        // No PackageMod is emitted for the dependency-only package
3186        // `common.v1` — it is not in `file_to_generate`.
3187        let pkg_mods = generated
3188            .iter()
3189            .filter(|f| f.kind == GeneratedFileKind::PackageMod)
3190            .count();
3191        assert_eq!(
3192            pkg_mods, 2,
3193            "expected exactly two PackageMods: {generated:#?}"
3194        );
3195
3196        // The cross-file Encodable<Reply> dedup must hold under
3197        // file_per_package exactly as it does under the per-proto split:
3198        // one impl pair across the whole batch (else E0119 at consumer
3199        // compile time). All three services return `.common.v1.Reply`.
3200        let combined: String = generated.iter().map(|f| f.content.as_str()).collect();
3201        assert_eq!(
3202            combined
3203                .matches("impl ::connectrpc::Encodable<super::super::common::v1::Reply>")
3204                .count(),
3205            2,
3206            "Encodable<Reply> impls must be deduplicated across packages \
3207             (1 for ReplyView, 1 for OwnedView<ReplyView>): {combined}"
3208        );
3209    }
3210
3211    #[test]
3212    fn generate_services_file_per_package_emits_one_file_per_package() {
3213        let files = file_per_package_fixture();
3214        let mut options = Options::default();
3215        options.buffa.file_per_package = true;
3216        options
3217            .buffa
3218            .extern_paths
3219            .push((".".into(), "crate::proto".into()));
3220
3221        let generated = generate_services(
3222            &files,
3223            &["a/x.proto".into(), "a/y.proto".into(), "b/z.proto".into()],
3224            &options,
3225        )
3226        .unwrap();
3227
3228        // Output is exactly one PackageMod per service-declaring package
3229        // with all stubs inlined; no companions, no `<pkg>.mod.rs` stitchers.
3230        assert_eq!(
3231            generated.len(),
3232            2,
3233            "expected exactly two output files: {generated:#?}"
3234        );
3235        assert!(
3236            generated
3237                .iter()
3238                .all(|f| f.kind == GeneratedFileKind::PackageMod),
3239            "all output files must be PackageMod"
3240        );
3241        assert!(
3242            !generated.iter().any(|f| f.name.ends_with(".mod.rs")),
3243            "file_per_package must not emit a separate stitcher"
3244        );
3245        assert!(
3246            !generated.iter().any(|f| f.content.contains("include!")),
3247            "file_per_package output must not include! sibling files"
3248        );
3249
3250        let mut names: Vec<&str> = generated.iter().map(|f| f.name.as_str()).collect();
3251        names.sort_unstable();
3252        assert_eq!(
3253            names,
3254            ["a.v1.rs", "b.v1.rs"],
3255            "filenames must be `<dotted.pkg>.rs` to match buffa's file_per_package convention"
3256        );
3257
3258        let a = generated.iter().find(|f| f.package == "a.v1").unwrap();
3259        assert!(a.content.contains("pub trait XService"));
3260        assert!(a.content.contains("pub trait YService"));
3261        let b = generated.iter().find(|f| f.package == "b.v1").unwrap();
3262        assert!(b.content.contains("pub trait ZService"));
3263        assert!(!b.content.contains("pub trait XService"));
3264    }
3265
3266    #[test]
3267    fn generate_services_file_per_package_default_layout_unchanged() {
3268        // Sanity: when the option is off, the existing per-proto + stitcher
3269        // layout is preserved (regression guard for the new branch).
3270        let files = file_per_package_fixture();
3271        let mut options = Options::default();
3272        options
3273            .buffa
3274            .extern_paths
3275            .push((".".into(), "crate::proto".into()));
3276
3277        let generated = generate_services(
3278            &files,
3279            &["a/x.proto".into(), "a/y.proto".into(), "b/z.proto".into()],
3280            &options,
3281        )
3282        .unwrap();
3283
3284        let mut companions: Vec<&str> = generated
3285            .iter()
3286            .filter(|f| f.kind == GeneratedFileKind::Companion)
3287            .map(|f| f.name.as_str())
3288            .collect();
3289        companions.sort_unstable();
3290        assert_eq!(
3291            companions,
3292            ["a.x.__connect.rs", "a.y.__connect.rs", "b.z.__connect.rs"],
3293            "default layout emits one companion per proto"
3294        );
3295        let mut stitchers: Vec<&str> = generated
3296            .iter()
3297            .filter(|f| f.kind == GeneratedFileKind::PackageMod)
3298            .map(|f| f.name.as_str())
3299            .collect();
3300        stitchers.sort_unstable();
3301        assert_eq!(
3302            stitchers,
3303            ["a.v1.mod.rs", "b.v1.mod.rs"],
3304            "default layout emits one stitcher per package"
3305        );
3306        // Each stitcher include!s its package's companions.
3307        let a_stitcher = generated.iter().find(|f| f.name == "a.v1.mod.rs").unwrap();
3308        assert!(
3309            a_stitcher
3310                .content
3311                .contains(r#"include!("a.x.__connect.rs");"#)
3312        );
3313        assert!(
3314            a_stitcher
3315                .content
3316                .contains(r#"include!("a.y.__connect.rs");"#)
3317        );
3318    }
3319
3320    #[test]
3321    fn service_name_with_package() {
3322        let file = minimal_file(
3323            Some("example.v1"),
3324            ".example.v1.PingReq",
3325            ".example.v1.PingResp",
3326            &["PingReq", "PingResp"],
3327        );
3328        let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
3329        assert!(code.contains("\"example.v1.PingService\""), "got: {code}");
3330    }
3331
3332    #[test]
3333    fn service_name_without_package() {
3334        // Empty package must produce "PingService", not ".PingService".
3335        let file = minimal_file(None, ".PingReq", ".PingResp", &["PingReq", "PingResp"]);
3336        let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
3337        assert!(code.contains("\"PingService\""), "got: {code}");
3338        assert!(
3339            !code.contains("\".PingService\""),
3340            "must not have leading dot: {code}"
3341        );
3342    }
3343
3344    #[test]
3345    fn same_package_types_use_bare_names() {
3346        let file = minimal_file(
3347            Some("example.v1"),
3348            ".example.v1.PingReq",
3349            ".example.v1.PingResp",
3350            &["PingReq", "PingResp"],
3351        );
3352        let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
3353        // Same-package types resolve to bare identifiers.
3354        assert!(code.contains("PingReq"), "input type missing: {code}");
3355        assert!(code.contains("PingResp"), "output type missing: {code}");
3356        // No super:: prefix for same-package types.
3357        assert!(
3358            !code.contains("super :: PingReq"),
3359            "unexpected super: {code}"
3360        );
3361    }
3362
3363    #[test]
3364    fn cross_package_types_use_relative_paths() {
3365        // Service in example.v1 references types from common.v1.
3366        // Must emit a super::-relative path matching buffa's module
3367        // layout, not bare `Shared` (which would fail to compile).
3368        let common = FileDescriptorProto {
3369            name: Some("common.proto".into()),
3370            package: Some("common.v1".into()),
3371            message_type: vec![DescriptorProto {
3372                name: Some("Shared".into()),
3373                ..Default::default()
3374            }],
3375            ..Default::default()
3376        };
3377        let svc = minimal_file(
3378            Some("example.v1"),
3379            ".common.v1.Shared",
3380            ".example.v1.Out",
3381            &["Out"],
3382        );
3383        let code = gen_service(&[common, svc], 1, &[], false).unwrap();
3384
3385        // example.v1 -> super::super -> common::v1::Shared
3386        // (token stream stringifies `::` with spaces, so match loosely)
3387        assert!(
3388            code.contains("super :: super :: common :: v1 :: Shared"),
3389            "cross-package path not emitted: {code}"
3390        );
3391        assert!(
3392            code.contains("super :: super :: common :: v1 :: __buffa :: view :: SharedView"),
3393            "cross-package view path not emitted: {code}"
3394        );
3395    }
3396
3397    #[test]
3398    fn nested_message_view_type_mirrors_owned_module_nesting() {
3399        // Service in example.v1 references Outer.Inner (nested under Outer).
3400        // buffa lays out the view as __buffa::view::outer::InnerView, mirroring
3401        // the owned outer::Inner layout. rust_view_type must insert the
3402        // sentinel at the package boundary, not at the type boundary.
3403        let file = FileDescriptorProto {
3404            name: Some("nested.proto".into()),
3405            package: Some("example.v1".into()),
3406            message_type: vec![
3407                DescriptorProto {
3408                    name: Some("Outer".into()),
3409                    nested_type: vec![DescriptorProto {
3410                        name: Some("Inner".into()),
3411                        ..Default::default()
3412                    }],
3413                    ..Default::default()
3414                },
3415                DescriptorProto {
3416                    name: Some("Out".into()),
3417                    ..Default::default()
3418                },
3419            ],
3420            service: vec![ServiceDescriptorProto {
3421                name: Some("NestedService".into()),
3422                method: vec![MethodDescriptorProto {
3423                    name: Some("Ping".into()),
3424                    input_type: Some(".example.v1.Outer.Inner".into()),
3425                    output_type: Some(".example.v1.Out".into()),
3426                    ..Default::default()
3427                }],
3428                ..Default::default()
3429            }],
3430            ..Default::default()
3431        };
3432        let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
3433
3434        assert!(
3435            code.contains("__buffa :: view :: outer :: InnerView"),
3436            "nested view path not emitted: {code}"
3437        );
3438        assert!(
3439            code.contains("outer :: Inner"),
3440            "nested owned path not emitted: {code}"
3441        );
3442    }
3443
3444    #[test]
3445    fn wkt_types_use_buffa_types_extern_path() {
3446        // Service referencing google.protobuf.Empty as an input/output
3447        // type. WKT auto-injection maps it to ::buffa_types::..., same
3448        // path buffa-codegen emits for WKT message fields.
3449        let wkt = FileDescriptorProto {
3450            name: Some("google/protobuf/empty.proto".into()),
3451            package: Some("google.protobuf".into()),
3452            message_type: vec![DescriptorProto {
3453                name: Some("Empty".into()),
3454                ..Default::default()
3455            }],
3456            ..Default::default()
3457        };
3458        let svc = minimal_file(
3459            Some("example.v1"),
3460            ".google.protobuf.Empty",
3461            ".example.v1.Out",
3462            &["Out"],
3463        );
3464        let code = gen_service(&[wkt, svc], 1, &[], false).unwrap();
3465
3466        assert!(
3467            code.contains(":: buffa_types :: google :: protobuf :: Empty"),
3468            "WKT extern path not emitted: {code}"
3469        );
3470    }
3471
3472    #[test]
3473    fn extern_catchall_uses_absolute_paths() {
3474        let file = minimal_file(
3475            Some("example.v1"),
3476            ".example.v1.PingReq",
3477            ".example.v1.PingResp",
3478            &["PingReq", "PingResp"],
3479        );
3480        let extern_paths = [(".".into(), "crate::proto".into())];
3481        let code = gen_service(std::slice::from_ref(&file), 0, &extern_paths, true).unwrap();
3482        assert!(
3483            code.contains("crate :: proto :: example :: v1 :: PingReq"),
3484            "owned type path missing: {code}"
3485        );
3486        assert!(
3487            code.contains("crate :: proto :: example :: v1 :: __buffa :: view :: PingReqView"),
3488            "view type path missing: {code}"
3489        );
3490    }
3491
3492    #[test]
3493    fn extern_catchall_with_wkt_longest_wins() {
3494        // Auto-injected `.google.protobuf` mapping is more specific than
3495        // the `.` catch-all, so WKTs still route to ::buffa_types.
3496        let wkt = FileDescriptorProto {
3497            name: Some("google/protobuf/empty.proto".into()),
3498            package: Some("google.protobuf".into()),
3499            message_type: vec![DescriptorProto {
3500                name: Some("Empty".into()),
3501                ..Default::default()
3502            }],
3503            ..Default::default()
3504        };
3505        let svc = minimal_file(
3506            Some("example.v1"),
3507            ".google.protobuf.Empty",
3508            ".example.v1.Out",
3509            &["Out"],
3510        );
3511        let extern_paths = [(".".into(), "crate::proto".into())];
3512        let code = gen_service(&[wkt, svc], 1, &extern_paths, true).unwrap();
3513        assert!(
3514            code.contains(":: buffa_types :: google :: protobuf :: Empty"),
3515            "WKT mapping lost to catch-all: {code}"
3516        );
3517        assert!(
3518            code.contains("crate :: proto :: example :: v1 :: Out"),
3519            "local type not routed through catch-all: {code}"
3520        );
3521    }
3522
3523    #[test]
3524    fn missing_extern_path_errors() {
3525        let file = minimal_file(
3526            Some("example.v1"),
3527            ".example.v1.PingReq",
3528            ".example.v1.PingResp",
3529            &["PingReq", "PingResp"],
3530        );
3531        let err = gen_service(std::slice::from_ref(&file), 0, &[], true).unwrap_err();
3532        let msg = err.to_string();
3533        assert!(
3534            msg.contains("extern_path"),
3535            "error message lacks hint: {msg}"
3536        );
3537    }
3538
3539    #[test]
3540    fn missing_descriptor_type_errors_on_build_script_path() {
3541        // A descriptor set built without `--include_imports` carries the
3542        // service but not the imported request type. The build-script path
3543        // must fail here rather than emit a reference to a type that exists
3544        // nowhere (issue #244).
3545        let file = minimal_file(
3546            Some("example.v1"),
3547            ".dep.v1.Dep",
3548            ".example.v1.PingResp",
3549            &["PingResp"],
3550        );
3551        let err = generate_files(
3552            std::slice::from_ref(&file),
3553            &["ping.proto".into()],
3554            &Options::default(),
3555        )
3556        .expect_err("a dangling method type must not generate successfully");
3557        let msg = err.to_string();
3558        assert!(
3559            msg.contains(".dep.v1.Dep") && msg.contains("descriptor set"),
3560            "error message should name the missing type: {msg}"
3561        );
3562        assert!(
3563            msg.contains("--include_imports"),
3564            "error message should point at the precompiled-set fix: {msg}"
3565        );
3566    }
3567
3568    #[test]
3569    fn imported_type_outside_file_to_generate_still_resolves() {
3570        // The strictness above keys on presence in the descriptor set, not
3571        // on membership in `file_to_generate`: an imported proto carried by
3572        // the set resolves even though no code is generated for it here.
3573        // This is how every WKT reference works, so it must keep working.
3574        let wkt = FileDescriptorProto {
3575            name: Some("google/protobuf/empty.proto".into()),
3576            package: Some("google.protobuf".into()),
3577            message_type: vec![DescriptorProto {
3578                name: Some("Empty".into()),
3579                ..Default::default()
3580            }],
3581            ..Default::default()
3582        };
3583        let svc = minimal_file(
3584            Some("example.v1"),
3585            ".google.protobuf.Empty",
3586            ".example.v1.PingResp",
3587            &["PingResp"],
3588        );
3589        let generated = generate_files(&[wkt, svc], &["ping.proto".into()], &Options::default())
3590            .expect("an imported type present in the set resolves");
3591        let all: String = generated.iter().map(|f| f.content.as_str()).collect();
3592        assert!(
3593            all.contains("buffa_types :: google :: protobuf :: Empty")
3594                || all.contains("buffa_types::google::protobuf::Empty"),
3595            "imported WKT should resolve through its extern mapping: {all}"
3596        );
3597    }
3598
3599    #[test]
3600    fn missing_descriptor_type_on_plugin_path_omits_precompiled_hint() {
3601        // protoc hands the plugin a complete import closure, so a plugin
3602        // user has no descriptor set of their own to rebuild — only the
3603        // missing-import half of the message applies.
3604        let file = minimal_file(
3605            Some("example.v1"),
3606            ".dep.v1.Dep",
3607            ".example.v1.PingResp",
3608            &["PingResp"],
3609        );
3610        let extern_paths = [(".".into(), "crate::proto".into())];
3611        let err = gen_service(std::slice::from_ref(&file), 0, &extern_paths, true).unwrap_err();
3612        let msg = err.to_string();
3613        assert!(
3614            msg.contains(".dep.v1.Dep") && msg.contains("missing proto import"),
3615            "error message should name the missing type: {msg}"
3616        );
3617        assert!(
3618            !msg.contains("--include_imports"),
3619            "precompiled-set hint does not apply to the plugin path: {msg}"
3620        );
3621    }
3622
3623    #[test]
3624    fn keyword_package_escaped() {
3625        // `google.type` -> `google::r#type` via idents::rust_path_to_tokens.
3626        let file = minimal_file(
3627            Some("google.type"),
3628            ".google.type.LatLng",
3629            ".google.type.LatLng",
3630            &["LatLng"],
3631        );
3632        let extern_paths = [(".".into(), "crate::proto".into())];
3633        let code = gen_service(std::slice::from_ref(&file), 0, &extern_paths, true).unwrap();
3634        assert!(
3635            code.contains("crate :: proto :: google :: r#type :: LatLng"),
3636            "keyword segment not escaped: {code}"
3637        );
3638    }
3639
3640    #[test]
3641    fn keyword_method_escaped() {
3642        // `rpc Move(...)` -> snake_case `move` is a Rust keyword; emit `r#move`
3643        // via idents::make_field_ident. Regression for issue #23.
3644        let file = minimal_file_with_method(
3645            Some("example.v1"),
3646            "Move",
3647            ".example.v1.Empty",
3648            ".example.v1.Empty",
3649            &["Empty"],
3650        );
3651        let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
3652        assert!(
3653            code.contains("fn r#move"),
3654            "keyword method not escaped: {code}"
3655        );
3656        assert!(
3657            code.contains("move_with_options"),
3658            "suffixed variant should not need escaping: {code}"
3659        );
3660        // Doc example should also use the escaped form so the snippet is valid.
3661        assert!(code.contains("client.r#move(request)"));
3662        syn::parse_str::<syn::File>(&code).expect("generated code parses");
3663    }
3664
3665    #[test]
3666    fn path_keyword_method_suffixed() {
3667        // `self`/`super`/`Self`/`crate` cannot be raw identifiers; they are
3668        // suffixed with `_` instead (matching prost convention).
3669        let file = minimal_file_with_method(
3670            Some("example.v1"),
3671            "Self",
3672            ".example.v1.Empty",
3673            ".example.v1.Empty",
3674            &["Empty"],
3675        );
3676        let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
3677        assert!(
3678            code.contains("fn self_"),
3679            "path-keyword method not suffixed: {code}"
3680        );
3681        // The `_with_options` variant uses the unsuffixed snake name; the
3682        // suffix already de-keywords it, so we get `self_with_options`
3683        // (not `self__with_options`).
3684        assert!(code.contains("self_with_options"));
3685        syn::parse_str::<syn::File>(&code).expect("generated code parses");
3686    }
3687
3688    #[test]
3689    fn service_name_keyword_suffixed() {
3690        // `service Self {}` is accepted by protoc but `Self` is a Rust keyword
3691        // that cannot be a raw ident; the bare trait name is suffixed `Self_`
3692        // while the derived `SelfExt`/`SelfClient`/`SelfServer` are already safe.
3693        let mut file = minimal_file(
3694            Some("example.v1"),
3695            ".example.v1.Empty",
3696            ".example.v1.Empty",
3697            &["Empty"],
3698        );
3699        file.service[0].name = Some("Self".into());
3700        let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
3701        assert!(code.contains("trait Self_ "), "trait not suffixed: {code}");
3702        assert!(code.contains("trait SelfExt"));
3703        assert!(code.contains("struct SelfClient"));
3704        assert!(code.contains("struct SelfServer"));
3705        syn::parse_str::<syn::File>(&code).expect("generated code parses");
3706    }
3707
3708    #[test]
3709    fn method_snake_collision_errors() {
3710        // protoc accepts `GetFoo` and `get_foo` in the same service; both
3711        // snake-case to `get_foo`, which would emit duplicate Rust methods.
3712        let file = minimal_file_with_methods("example.v1", &["GetFoo", "get_foo"]);
3713        let err = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap_err();
3714        let msg = err.to_string();
3715        assert!(msg.contains("PingService"), "missing service name: {msg}");
3716        assert!(msg.contains("\"GetFoo\""), "missing first method: {msg}");
3717        assert!(msg.contains("\"get_foo\""), "missing second method: {msg}");
3718        assert!(msg.contains("`get_foo`"), "missing rust ident: {msg}");
3719    }
3720
3721    #[test]
3722    fn method_with_options_collision_errors() {
3723        // `Ping` generates client method `ping_with_options`; a proto method
3724        // `PingWithOptions` would generate the same base name.
3725        let file = minimal_file_with_methods("example.v1", &["Ping", "PingWithOptions"]);
3726        let err = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap_err();
3727        let msg = err.to_string();
3728        assert!(msg.contains("\"Ping\""), "missing first method: {msg}");
3729        assert!(
3730            msg.contains("\"PingWithOptions\""),
3731            "missing second method: {msg}"
3732        );
3733        assert!(
3734            msg.contains("`ping_with_options`"),
3735            "missing rust ident: {msg}"
3736        );
3737    }
3738
3739    #[test]
3740    fn distinct_methods_do_not_collide() {
3741        let file = minimal_file_with_methods("example.v1", &["GetFoo", "GetBar"]);
3742        let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
3743        syn::parse_str::<syn::File>(&code).expect("generated code parses");
3744    }
3745
3746    #[test]
3747    fn options_default_buffa_config() {
3748        let cfg = Options::default().to_buffa_config();
3749        assert!(cfg.generate_json, "connectrpc enables JSON by default");
3750        assert!(cfg.generate_views);
3751        assert!(cfg.emit_register_fn);
3752        assert!(!cfg.strict_utf8_mapping);
3753    }
3754
3755    #[test]
3756    fn options_buffa_passthrough_forces_views() {
3757        let mut opts = Options::default();
3758        opts.buffa.emit_register_fn = false;
3759        opts.buffa.generate_views = false;
3760        let cfg = opts.to_buffa_config();
3761        assert!(!cfg.emit_register_fn);
3762        assert!(cfg.generate_views, "generate_views must be forced on");
3763    }
3764
3765    #[test]
3766    fn generate_files_emit_register_fn_false_suppresses_register_types() {
3767        // Build a file with a single message so buffa would normally emit
3768        // `pub fn register_types(&mut TypeRegistry)` aggregating it.
3769        let file = FileDescriptorProto {
3770            name: Some("ping.proto".into()),
3771            package: Some("example.v1".into()),
3772            message_type: vec![DescriptorProto {
3773                name: Some("PingReq".into()),
3774                ..Default::default()
3775            }],
3776            ..Default::default()
3777        };
3778
3779        // `register_types` is emitted into the per-package stitcher, so
3780        // locate the PackageMod output and check that one.
3781        let stitcher = |files: &[GeneratedFile]| {
3782            files
3783                .iter()
3784                .find(|f| f.kind == GeneratedFileKind::PackageMod)
3785                .expect("PackageMod file emitted")
3786                .content
3787                .clone()
3788        };
3789
3790        let with_fn = generate_files(
3791            std::slice::from_ref(&file),
3792            &["ping.proto".into()],
3793            &Options::default(),
3794        )
3795        .unwrap();
3796        let mod_rs = stitcher(&with_fn);
3797        assert!(
3798            mod_rs.contains("fn register_types"),
3799            "expected register_types in default output: {mod_rs}"
3800        );
3801
3802        let mut opts = Options::default();
3803        opts.buffa.emit_register_fn = false;
3804        let without_fn =
3805            generate_files(std::slice::from_ref(&file), &["ping.proto".into()], &opts).unwrap();
3806        let mod_rs = stitcher(&without_fn);
3807        assert!(
3808            !mod_rs.contains("fn register_types"),
3809            "register_types should be suppressed: {mod_rs}"
3810        );
3811    }
3812
3813    #[test]
3814    fn plugin_no_register_fn_parses() {
3815        let request = CodeGeneratorRequest {
3816            parameter: Some("buffa_module=crate::proto,no_register_fn".into()),
3817            file_to_generate: vec![],
3818            proto_file: vec![],
3819            ..Default::default()
3820        };
3821        // Plugin path emits services only, so we can't observe the buffa
3822        // config directly — just make sure the option parses without error.
3823        generate(&request).expect("no_register_fn should be a recognized plugin option");
3824    }
3825
3826    /// Format `generate_service` output for a single-service file using
3827    /// the local `minimal_file` fixture. `gate_client_feature` selects
3828    /// whether the opt-in cfg attr is emitted; shared by the `*_client_*`
3829    /// tests below.
3830    fn format_minimal_service(gate_client_feature: bool) -> String {
3831        format_minimal_service_with_client_feature_name(gate_client_feature, "client")
3832    }
3833
3834    fn format_minimal_service_with_client_feature_name(
3835        gate_client_feature: bool,
3836        client_feature_name: &str,
3837    ) -> String {
3838        let file = minimal_file(
3839            Some("example.v1"),
3840            ".example.v1.PingReq",
3841            ".example.v1.PingResp",
3842            &["PingReq", "PingResp"],
3843        );
3844        let config = buffa_codegen::CodeGenConfig::default();
3845        let target = file.name.clone().into_iter().collect::<Vec<_>>();
3846        let resolver = TypeResolver::new(std::slice::from_ref(&file), &target, &config, false);
3847        let service = &file.service[0];
3848        let batch = BatchState {
3849            colliding_aliases: collect_alias_collisions(std::slice::from_ref(&file), &target),
3850            gate_client_feature,
3851            client_feature_name: client_feature_name.to_string(),
3852            ..BatchState::default()
3853        };
3854        format_token_stream(&generate_service(&file, service, &resolver, &batch).unwrap()).unwrap()
3855    }
3856
3857    #[test]
3858    fn default_emission_has_no_client_cfg() {
3859        // CRITICAL invariant: with the option unset, codegen emits zero
3860        // `#[cfg(feature = "client")]` attrs. External users with their
3861        // own protos must not be forced to declare a Cargo feature.
3862        let out = format_minimal_service(false);
3863        assert!(
3864            !out.contains("#[cfg(feature ="),
3865            "default emission must not emit any cfg attr — external \
3866             consumers should not need to declare a `client` Cargo \
3867             feature unless they explicitly opt in via the \
3868             `gate_client_feature` plugin option:\n{out}"
3869        );
3870    }
3871
3872    #[test]
3873    fn client_items_gated_when_opt_in() {
3874        // When `gate_client_feature` is set, the `FooClient` struct +
3875        // impl carry `#[cfg(feature = "client")]`. Exactly two attrs:
3876        // one on the struct, one on the impl block. (All `_with_options`
3877        // methods live inside the impl and inherit the gate.)
3878        let out = format_minimal_service(true);
3879        let cfg_count = out.matches("#[cfg(feature = \"client\")]").count();
3880        assert_eq!(
3881            cfg_count, 2,
3882            "expected exactly two #[cfg(feature = \"client\")] attrs (one on \
3883             `pub struct PingServiceClient`, one on its `impl<T>` block); got \
3884             {cfg_count}:\n{out}"
3885        );
3886    }
3887
3888    #[test]
3889    fn client_items_use_custom_feature_name_when_configured() {
3890        let out = format_minimal_service_with_client_feature_name(true, "grpc-client");
3891        let cfg_count = out.matches("#[cfg(feature = \"grpc-client\")]").count();
3892        assert_eq!(
3893            cfg_count, 2,
3894            "expected exactly two #[cfg(feature = \"grpc-client\")] attrs \
3895             (one on `pub struct PingServiceClient`, one on its `impl<T>` \
3896             block); got {cfg_count}:\n{out}"
3897        );
3898        assert!(
3899            !out.contains("#[cfg(feature = \"client\")]"),
3900            "custom feature name must replace the default `client` gate:\n{out}"
3901        );
3902    }
3903
3904    #[test]
3905    fn server_items_never_carry_client_cfg() {
3906        // The trait, ext trait, and monomorphic dispatcher live on the
3907        // server side; nothing about them should be feature-gated even
3908        // under the opt-in path.
3909        let out = format_minimal_service(true);
3910        for marker in [
3911            "pub trait PingService",
3912            "pub trait PingServiceExt",
3913            "pub struct PingServiceRegisterMarker",
3914            "pub struct PingServiceServer",
3915            "pub const PING_SERVICE_SERVICE_NAME",
3916        ] {
3917            let idx = out
3918                .find(marker)
3919                .unwrap_or_else(|| panic!("expected `{marker}` in output:\n{out}"));
3920            let prefix = &out[..idx];
3921            assert!(
3922                !prefix.trim_end().ends_with("#[cfg(feature = \"client\")]"),
3923                "`{marker}` must not be preceded by a client cfg attr — \
3924                 server-side items are always compiled in:\n{out}"
3925            );
3926        }
3927    }
3928
3929    #[test]
3930    fn service_register_impl_and_marker_are_generated() {
3931        let out = format_minimal_service(false);
3932        assert!(
3933            out.contains("pub struct PingServiceRegisterMarker;"),
3934            "generated service must expose an inference marker:\n{out}"
3935        );
3936        assert!(
3937            out.contains(
3938                "impl<S: PingService> ::connectrpc::ServiceRegister<PingServiceRegisterMarker>"
3939            ),
3940            "generated service must implement ServiceRegister for Arc<S>:\n{out}"
3941        );
3942        assert!(
3943            out.contains("for ::std::sync::Arc<S>"),
3944            "ServiceRegister implementation must accept Arc<S>:\n{out}"
3945        );
3946        assert!(
3947            out.contains("fn register_service(self, router: ::connectrpc::Router)"),
3948            "ServiceRegister implementation must expose the bridge method:\n{out}"
3949        );
3950        assert!(
3951            out.contains("<S as PingServiceExt>::register(self, router)"),
3952            "ServiceRegister must forward to the existing extension trait:\n{out}"
3953        );
3954    }
3955
3956    /// The strongest invariant: every reference to
3957    /// `::connectrpc::client::*` (or the unqualified `connectrpc::client::`
3958    /// — should not appear, but guard anyway) must live inside an item
3959    /// (or ancestor module/item) carrying `#[cfg(feature = "client")]`.
3960    /// Catches the missing-gate regression that a count-only test cannot
3961    /// detect: e.g. a future `impl<T> Default for FooClient<T>` that the
3962    /// contributor forgot to prefix.
3963    ///
3964    /// Walks recursively into `Item::Mod` bodies so a gate on a parent
3965    /// module implicitly covers its children — avoids false-positives
3966    /// where a wrapper `pub mod gated { #[cfg(...)] … }` would flag the
3967    /// outer module just because its rendered body mentions
3968    /// `::connectrpc::client::`.
3969    #[test]
3970    fn no_ungated_client_references() {
3971        // Only relevant under the opt-in path — that's where the
3972        // invariant ("every `::connectrpc::client::*` reference lives
3973        // inside a gated item") is meaningful.
3974        let out = format_minimal_service(true);
3975        let parsed: syn::File = syn::parse_str(&out).expect("output parses");
3976
3977        let mut offenders: Vec<String> = Vec::new();
3978        scan_items_for_ungated_client_refs(&parsed.items, false, &mut offenders);
3979        assert!(
3980            offenders.is_empty(),
3981            "every item that mentions `::connectrpc::client::*` must be \
3982             prefixed with `#[cfg(feature = \"client\")]`. Offenders:\n{}\n\nFull output:\n{out}",
3983            offenders.join("\n")
3984        );
3985    }
3986
3987    /// Predicate: is this attribute `#[cfg(feature = "client")]`?
3988    /// Stringifies the attr to avoid coupling to syn's parsed `Meta`
3989    /// shape across versions.
3990    fn is_client_feature_cfg(attr: &syn::Attribute) -> bool {
3991        attr.path().is_ident("cfg")
3992            && attr
3993                .to_token_stream()
3994                .to_string()
3995                .contains("feature = \"client\"")
3996    }
3997
3998    /// Render `ts` through prettyplease (matching the spacing of the
3999    /// rest of the codegen test surface) and check for any reference
4000    /// to `::connectrpc::client::` or `connectrpc :: client ::` (the
4001    /// pre-prettyplease form, defensive).
4002    fn mentions_connectrpc_client(ts: TokenStream) -> bool {
4003        let rendered = format_token_stream(&ts).unwrap_or_default();
4004        rendered.contains("::connectrpc::client::") || rendered.contains("connectrpc :: client ::")
4005    }
4006
4007    /// Recursive walker for `no_ungated_client_references`. For each
4008    /// item: if the item or any ancestor is `#[cfg(feature = "client")]`,
4009    /// it's gated and we skip. Otherwise, if its rendered tokens
4010    /// mention `::connectrpc::client::`, push an offender entry.
4011    /// `Item::Mod` recurses into its children so a parent-level gate
4012    /// implicitly covers them.
4013    ///
4014    /// Item kinds the codegen doesn't currently emit at top level
4015    /// (`Use`, `Static`, `Macro`, `ForeignMod`, `Union`, `TraitAlias`,
4016    /// `ExternCrate`, `Verbatim`, …) still go through the textual scan
4017    /// via the fallthrough arm — they're not gated by anything we can
4018    /// inspect, so if their token rendering mentions
4019    /// `::connectrpc::client::` they're flagged. This is the defensive
4020    /// shape: a future emission that introduces e.g. an ungated
4021    /// `use ::connectrpc::client::ClientConfig;` at module scope must
4022    /// not slip past the invariant test.
4023    fn scan_items_for_ungated_client_refs(
4024        items: &[syn::Item],
4025        ancestor_gated: bool,
4026        offenders: &mut Vec<String>,
4027    ) {
4028        for item in items {
4029            // Extract attrs for the kinds we explicitly model. For
4030            // everything else we treat the item as not self-gated and
4031            // fall through to the textual scan — better a false
4032            // positive on an exotic ungated emission than silently
4033            // missing a real one.
4034            let (attrs, ident): (&[syn::Attribute], String) = match item {
4035                syn::Item::Struct(s) => (&s.attrs, s.ident.to_string()),
4036                syn::Item::Impl(i) => (
4037                    &i.attrs,
4038                    format!("impl-block for {}", ToTokens::to_token_stream(&i.self_ty)),
4039                ),
4040                syn::Item::Fn(f) => (&f.attrs, f.sig.ident.to_string()),
4041                syn::Item::Trait(t) => (&t.attrs, t.ident.to_string()),
4042                syn::Item::Const(c) => (&c.attrs, c.ident.to_string()),
4043                syn::Item::Type(t) => (&t.attrs, t.ident.to_string()),
4044                syn::Item::Static(s) => (&s.attrs, s.ident.to_string()),
4045                syn::Item::Use(u) => (&u.attrs, "use-item".to_string()),
4046                syn::Item::ExternCrate(e) => (&e.attrs, e.ident.to_string()),
4047                syn::Item::Macro(m) => (
4048                    &m.attrs,
4049                    m.ident
4050                        .as_ref()
4051                        .map(syn::Ident::to_string)
4052                        .unwrap_or_else(|| "macro-item".to_string()),
4053                ),
4054                syn::Item::ForeignMod(f) => (&f.attrs, "extern-block".to_string()),
4055                syn::Item::Union(u) => (&u.attrs, u.ident.to_string()),
4056                syn::Item::TraitAlias(t) => (&t.attrs, t.ident.to_string()),
4057                syn::Item::Enum(e) => (&e.attrs, e.ident.to_string()),
4058                syn::Item::Mod(m) => {
4059                    let self_gated = m.attrs.iter().any(is_client_feature_cfg);
4060                    let gated = ancestor_gated || self_gated;
4061                    if let Some((_brace, children)) = &m.content {
4062                        scan_items_for_ungated_client_refs(children, gated, offenders);
4063                    }
4064                    // Don't fall through — the textual scan on a Mod's
4065                    // tokens would render its children too and double-count.
4066                    continue;
4067                }
4068                // `Item::Verbatim` and any future syn variant: we can't
4069                // inspect attrs, so assume not self-gated and let the
4070                // textual scan decide.
4071                _ => (&[][..], "<unrecognized item>".to_string()),
4072            };
4073            let self_gated = attrs.iter().any(is_client_feature_cfg);
4074            let gated = ancestor_gated || self_gated;
4075            if gated {
4076                continue;
4077            }
4078            if mentions_connectrpc_client(ToTokens::to_token_stream(item)) {
4079                offenders.push(format!(
4080                    "ungated reference to ::connectrpc::client in `{ident}`"
4081                ));
4082            }
4083        }
4084    }
4085
4086    /// Verify the recursive scanner: a parent module gated on `client`
4087    /// covers its children (no false-positive); an ungated parent
4088    /// containing an ungated child gets flagged via the child, not the
4089    /// parent's textual rendering (no double-counting).
4090    #[test]
4091    fn ungated_scanner_handles_nested_modules() {
4092        // Case 1: gated parent + ungated-looking child → no offenders.
4093        let parsed: syn::File = syn::parse_str(
4094            r#"
4095            #[cfg(feature = "client")]
4096            pub mod gated_parent {
4097                pub struct WithClientRef {
4098                    field: ::connectrpc::client::ClientConfig,
4099                }
4100            }
4101            "#,
4102        )
4103        .unwrap();
4104        let mut offenders = Vec::new();
4105        scan_items_for_ungated_client_refs(&parsed.items, false, &mut offenders);
4106        assert!(
4107            offenders.is_empty(),
4108            "parent-level cfg must cover children: {offenders:?}"
4109        );
4110
4111        // Case 2: ungated parent + ungated child referencing client → exactly
4112        // ONE offender (the inner struct), not two (parent + child).
4113        let parsed: syn::File = syn::parse_str(
4114            r#"
4115            pub mod ungated_parent {
4116                pub struct WithClientRef {
4117                    field: ::connectrpc::client::ClientConfig,
4118                }
4119            }
4120            "#,
4121        )
4122        .unwrap();
4123        let mut offenders = Vec::new();
4124        scan_items_for_ungated_client_refs(&parsed.items, false, &mut offenders);
4125        assert_eq!(
4126            offenders.len(),
4127            1,
4128            "exactly one offender expected (the inner struct), not the wrapping \
4129             module: {offenders:?}"
4130        );
4131        assert!(
4132            offenders[0].contains("WithClientRef"),
4133            "offender should name the inner struct: {:?}",
4134            offenders[0]
4135        );
4136
4137        // Case 3: ungated parent containing a gated child → no offenders.
4138        let parsed: syn::File = syn::parse_str(
4139            r#"
4140            pub mod outer {
4141                #[cfg(feature = "client")]
4142                pub struct GatedClient {
4143                    field: ::connectrpc::client::ClientConfig,
4144                }
4145            }
4146            "#,
4147        )
4148        .unwrap();
4149        let mut offenders = Vec::new();
4150        scan_items_for_ungated_client_refs(&parsed.items, false, &mut offenders);
4151        assert!(
4152            offenders.is_empty(),
4153            "self-gating child inside ungated module must be OK: {offenders:?}"
4154        );
4155    }
4156
4157    /// Regression: the scanner must not silently skip `syn::Item` variants
4158    /// the codegen doesn't currently emit. A future ungated
4159    /// `use ::connectrpc::client::ClientConfig;` or a `static`
4160    /// referencing the client module would have slipped past the
4161    /// earlier `_ => continue` catch-all; the expanded variant arms +
4162    /// fallthrough textual scan catch it now.
4163    #[test]
4164    fn ungated_scanner_catches_use_and_static_items() {
4165        // Item::Use, ungated → flagged.
4166        let parsed: syn::File = syn::parse_str("use ::connectrpc::client::ClientConfig;").unwrap();
4167        let mut offenders = Vec::new();
4168        scan_items_for_ungated_client_refs(&parsed.items, false, &mut offenders);
4169        assert_eq!(
4170            offenders.len(),
4171            1,
4172            "ungated `use ::connectrpc::client::*` must be flagged: {offenders:?}"
4173        );
4174
4175        // Item::Use, gated → OK.
4176        let parsed: syn::File =
4177            syn::parse_str("#[cfg(feature = \"client\")] use ::connectrpc::client::ClientConfig;")
4178                .unwrap();
4179        let mut offenders = Vec::new();
4180        scan_items_for_ungated_client_refs(&parsed.items, false, &mut offenders);
4181        assert!(
4182            offenders.is_empty(),
4183            "gated `use ::connectrpc::client::*` must NOT be flagged: {offenders:?}"
4184        );
4185
4186        // Item::Static, ungated, referencing client module → flagged.
4187        let parsed: syn::File =
4188            syn::parse_str("static FOO: &str = stringify!(::connectrpc::client::ClientConfig);")
4189                .unwrap();
4190        let mut offenders = Vec::new();
4191        scan_items_for_ungated_client_refs(&parsed.items, false, &mut offenders);
4192        assert_eq!(
4193            offenders.len(),
4194            1,
4195            "ungated `static FOO` mentioning ::connectrpc::client must be flagged: \
4196             {offenders:?}"
4197        );
4198    }
4199
4200    #[test]
4201    fn client_cfg_round_trips_through_prettyplease() {
4202        // Sanity: prettyplease formats the cfg attr to exactly the
4203        // canonical spelling we grep for in the count test. If a future
4204        // formatting change reshapes the attribute (e.g. inserts spaces),
4205        // the count test would silently report zero matches — make sure
4206        // we'd notice.
4207        let out = format_minimal_service(true);
4208        // The exact rendered form prettyplease uses; if this assertion
4209        // ever fails we need to update the other test's grep pattern.
4210        assert!(
4211            out.contains("#[cfg(feature = \"client\")]"),
4212            "prettyplease no longer renders the cfg attr as expected; \
4213             update the grep pattern in client_items_always_gated:\n{out}"
4214        );
4215    }
4216
4217    #[test]
4218    fn multi_service_in_one_file_each_client_is_gated() {
4219        // Two services in the same file → 4 cfg attrs (2 per FooClient).
4220        // Catches a regression where the cfg interpolation accidentally
4221        // moved outside the per-service token block.
4222        let make_service = |name: &str| ServiceDescriptorProto {
4223            name: Some(name.into()),
4224            method: vec![MethodDescriptorProto {
4225                name: Some("Ping".into()),
4226                input_type: Some(".example.v1.PingReq".into()),
4227                output_type: Some(".example.v1.PingResp".into()),
4228                ..Default::default()
4229            }],
4230            ..Default::default()
4231        };
4232        let file = FileDescriptorProto {
4233            name: Some("two.proto".into()),
4234            package: Some("example.v1".into()),
4235            service: vec![make_service("Alpha"), make_service("Beta")],
4236            message_type: vec![
4237                DescriptorProto {
4238                    name: Some("PingReq".into()),
4239                    ..Default::default()
4240                },
4241                DescriptorProto {
4242                    name: Some("PingResp".into()),
4243                    ..Default::default()
4244                },
4245            ],
4246            ..Default::default()
4247        };
4248        let config = buffa_codegen::CodeGenConfig::default();
4249        let target = vec!["two.proto".to_string()];
4250        let resolver = TypeResolver::new(std::slice::from_ref(&file), &target, &config, false);
4251        let mut batch = BatchState {
4252            colliding_aliases: collect_alias_collisions(std::slice::from_ref(&file), &target),
4253            gate_client_feature: true,
4254            ..BatchState::default()
4255        };
4256        let ts = generate_connect_services(&file, &resolver, &mut batch).unwrap();
4257        let out = format_token_stream(&ts).unwrap();
4258        let cfg_count = out.matches("#[cfg(feature = \"client\")]").count();
4259        assert_eq!(
4260            cfg_count, 4,
4261            "expected 4 client cfg attrs (2 per service * 2 services); got \
4262             {cfg_count}:\n{out}"
4263        );
4264        // Both client structs are present, both gated.
4265        for client_struct in ["pub struct AlphaClient", "pub struct BetaClient"] {
4266            let idx = out
4267                .find(client_struct)
4268                .unwrap_or_else(|| panic!("expected `{client_struct}` in output:\n{out}"));
4269            let prefix = &out[..idx];
4270            assert!(
4271                prefix.trim_end().ends_with("#[derive(Clone)]")
4272                    || prefix.contains("#[cfg(feature = \"client\")]"),
4273                "`{client_struct}` must have a client cfg attr in its \
4274                 attribute cluster:\n{out}"
4275            );
4276        }
4277    }
4278
4279    #[test]
4280    fn plugin_accepts_gate_client_feature_flag() {
4281        // The current option is a bare flag (no `=value`).
4282        let request = CodeGeneratorRequest {
4283            parameter: Some("buffa_module=crate::proto,gate_client_feature".into()),
4284            file_to_generate: vec![],
4285            proto_file: vec![],
4286            ..Default::default()
4287        };
4288        generate(&request).expect("gate_client_feature should be a recognized plugin option");
4289    }
4290
4291    #[test]
4292    fn plugin_accepts_gate_client_feature_value_form() {
4293        let file = minimal_file(
4294            Some("example.v1"),
4295            ".example.v1.PingReq",
4296            ".example.v1.PingResp",
4297            &["PingReq", "PingResp"],
4298        );
4299        let request = CodeGeneratorRequest {
4300            parameter: Some("buffa_module=crate::proto,gate_client_feature=grpc-client".into()),
4301            file_to_generate: vec!["ping.proto".into()],
4302            proto_file: vec![file],
4303            ..Default::default()
4304        };
4305        let response =
4306            generate(&request).expect("custom gate_client_feature value should be recognized");
4307        let connect_file = response
4308            .file
4309            .iter()
4310            .find(|f| f.name.as_deref() == Some("ping.__connect.rs"))
4311            .expect("plugin should emit a connect service companion");
4312        let content = connect_file.content.as_deref().unwrap_or_default();
4313        let cfg_count = content.matches("#[cfg(feature = \"grpc-client\")]").count();
4314        assert_eq!(
4315            cfg_count, 2,
4316            "expected custom feature gate on generated client struct and impl; got \
4317             {cfg_count}:\n{content}"
4318        );
4319        assert!(
4320            !content.contains("#[cfg(feature = \"client\")]"),
4321            "custom plugin feature name must replace the default gate:\n{content}"
4322        );
4323    }
4324
4325    #[test]
4326    fn plugin_rejects_empty_gate_client_feature_value() {
4327        let request = CodeGeneratorRequest {
4328            parameter: Some("buffa_module=crate::proto,gate_client_feature=".into()),
4329            file_to_generate: vec![],
4330            proto_file: vec![],
4331            ..Default::default()
4332        };
4333        let err = generate(&request).expect_err("empty gate_client_feature value must be rejected");
4334        let msg = err.to_string();
4335        assert!(
4336            msg.contains("gate_client_feature requires a non-empty feature name"),
4337            "error should describe the empty feature-name problem: {msg}"
4338        );
4339    }
4340
4341    #[test]
4342    fn options_reject_invalid_client_feature_name() {
4343        let opts = Options {
4344            gate_client_feature: true,
4345            client_feature_name: "grpc client".into(),
4346            ..Options::default()
4347        };
4348        let err = generate_services(&[], &[], &opts)
4349            .expect_err("invalid client feature name must be rejected");
4350        assert!(
4351            err.to_string().contains("not a valid Cargo feature name"),
4352            "error should name the grammar problem: {err}"
4353        );
4354    }
4355
4356    /// Split-path options with `EncodableImpls::AllMessages` and the
4357    /// `.` → `crate::proto` catch-all, mirroring a types-crate generation
4358    /// run; `extra_extern` prepends higher-priority package mappings.
4359    fn all_messages_options(extra_extern: &[(&str, &str)]) -> Options {
4360        let mut options = Options {
4361            encodable_impls: EncodableImpls::AllMessages,
4362            ..Options::default()
4363        };
4364        for (proto, rust) in extra_extern {
4365            options
4366                .buffa
4367                .extern_paths
4368                .push(((*proto).into(), (*rust).into()));
4369        }
4370        options
4371            .buffa
4372            .extern_paths
4373            .push((".".into(), "crate::proto".into()));
4374        options
4375    }
4376
4377    #[test]
4378    fn all_messages_emits_impls_for_serviceless_proto() {
4379        let file = FileDescriptorProto {
4380            name: Some("common.proto".into()),
4381            package: Some("common.v1".into()),
4382            message_type: vec![DescriptorProto {
4383                name: Some("Shared".into()),
4384                ..Default::default()
4385            }],
4386            ..Default::default()
4387        };
4388        let generated = generate_services(
4389            std::slice::from_ref(&file),
4390            &["common.proto".into()],
4391            &all_messages_options(&[]),
4392        )
4393        .unwrap();
4394
4395        let companion = generated
4396            .iter()
4397            .find(|f| f.name == "common.__connect.rs")
4398            .expect("service-less proto must get a companion under all_messages");
4399        assert_eq!(
4400            companion
4401                .content
4402                .matches("impl ::connectrpc::Encodable<")
4403                .count(),
4404            2,
4405            "one view + one OwnedView impl: {}",
4406            companion.content
4407        );
4408        assert!(
4409            companion.content.contains("SharedView"),
4410            "impls must target the view type: {}",
4411            companion.content
4412        );
4413        // The package stitcher must wire the companion in.
4414        let stitcher = generated
4415            .iter()
4416            .find(|f| f.kind == GeneratedFileKind::PackageMod)
4417            .expect("package stitcher for the companion");
4418        assert!(
4419            stitcher
4420                .content
4421                .contains("include!(\"common.__connect.rs\")"),
4422            "stitcher must include the companion: {}",
4423            stitcher.content
4424        );
4425    }
4426
4427    #[test]
4428    fn all_messages_skips_extern_mapped_proto_entirely() {
4429        // The whole package is mapped to a foreign crate: every impl would
4430        // be an orphan there, so no impls — and no empty companion file.
4431        let file = FileDescriptorProto {
4432            name: Some("common.proto".into()),
4433            package: Some("common.v1".into()),
4434            message_type: vec![DescriptorProto {
4435                name: Some("Shared".into()),
4436                ..Default::default()
4437            }],
4438            ..Default::default()
4439        };
4440        let generated = generate_services(
4441            std::slice::from_ref(&file),
4442            &["common.proto".into()],
4443            &all_messages_options(&[(".common.v1", "::common_protos::proto::common::v1")]),
4444        )
4445        .unwrap();
4446        assert!(
4447            generated.is_empty(),
4448            "foreign-mapped proto must produce no files: {:?}",
4449            generated.iter().map(|f| &f.name).collect::<Vec<_>>()
4450        );
4451    }
4452
4453    #[test]
4454    fn all_messages_dedups_with_service_output_impls() {
4455        // PingResp is both an RPC output (outputs-driven emission) and a
4456        // file-local message (all-messages emission): exactly one impl
4457        // pair must survive, plus PingReq's pair from all-messages.
4458        let file = minimal_file(
4459            Some("example.v1"),
4460            ".example.v1.PingReq",
4461            ".example.v1.PingResp",
4462            &["PingReq", "PingResp"],
4463        );
4464        let generated = generate_services(
4465            std::slice::from_ref(&file),
4466            &["ping.proto".into()],
4467            &all_messages_options(&[]),
4468        )
4469        .unwrap();
4470        let companion = generated
4471            .iter()
4472            .find(|f| f.name == "ping.__connect.rs")
4473            .expect("service companion");
4474        // Count impl bodies rather than `impl ::connectrpc::Encodable<` —
4475        // that string also appears in the trait method's return-position
4476        // bound. Match `encode_view_body(` with the paren so the
4477        // `encode_view_body_segments` override on the OwnedView impl is not
4478        // counted as a second body.
4479        assert_eq!(
4480            companion.content.matches("encode_view_body(").count(),
4481            4,
4482            "exactly one impl pair per message (PingReq + PingResp), \
4483             no E0119 duplicates: {}",
4484            companion.content
4485        );
4486        assert!(companion.content.contains("PingReqView"));
4487        assert!(companion.content.contains("PingRespView"));
4488    }
4489
4490    #[test]
4491    fn all_messages_recurses_nested_and_skips_map_entries() {
4492        use buffa_codegen::generated::descriptor::MessageOptions;
4493        let file = FileDescriptorProto {
4494            name: Some("nested.proto".into()),
4495            package: Some("example.v1".into()),
4496            message_type: vec![DescriptorProto {
4497                name: Some("Outer".into()),
4498                nested_type: vec![
4499                    DescriptorProto {
4500                        name: Some("Inner".into()),
4501                        ..Default::default()
4502                    },
4503                    DescriptorProto {
4504                        name: Some("LabelsEntry".into()),
4505                        options: buffa::MessageField::some(MessageOptions {
4506                            map_entry: Some(true),
4507                            ..Default::default()
4508                        }),
4509                        ..Default::default()
4510                    },
4511                ],
4512                ..Default::default()
4513            }],
4514            ..Default::default()
4515        };
4516        let generated = generate_services(
4517            std::slice::from_ref(&file),
4518            &["nested.proto".into()],
4519            &all_messages_options(&[]),
4520        )
4521        .unwrap();
4522        let companion = generated
4523            .iter()
4524            .find(|f| f.name == "nested.__connect.rs")
4525            .expect("companion with nested-message impls");
4526        assert_eq!(
4527            companion
4528                .content
4529                .matches("impl ::connectrpc::Encodable<")
4530                .count(),
4531            4,
4532            "impl pairs for Outer and Outer.Inner only: {}",
4533            companion.content
4534        );
4535        assert!(
4536            companion.content.contains("InnerView"),
4537            "nested message must get impls: {}",
4538            companion.content
4539        );
4540        assert!(
4541            !companion.content.contains("LabelsEntry"),
4542            "synthetic map entries must not get impls: {}",
4543            companion.content
4544        );
4545    }
4546
4547    #[test]
4548    fn all_messages_file_per_package_collapses_serviceless_output() {
4549        // Split path + file_per_package: a service-less proto's impls must
4550        // land in the single `<dotted.pkg>.rs` PackageMod, with no
4551        // companion or stitcher siblings.
4552        let file = FileDescriptorProto {
4553            name: Some("common.proto".into()),
4554            package: Some("common.v1".into()),
4555            message_type: vec![DescriptorProto {
4556                name: Some("Shared".into()),
4557                ..Default::default()
4558            }],
4559            ..Default::default()
4560        };
4561        let mut options = all_messages_options(&[]);
4562        options.buffa.file_per_package = true;
4563        let generated = generate_services(
4564            std::slice::from_ref(&file),
4565            &["common.proto".into()],
4566            &options,
4567        )
4568        .unwrap();
4569        assert_eq!(generated.len(), 1, "exactly one PackageMod file");
4570        let pkg_mod = &generated[0];
4571        assert_eq!(pkg_mod.kind, GeneratedFileKind::PackageMod);
4572        assert_eq!(pkg_mod.name, "common.v1.rs");
4573        assert_eq!(
4574            pkg_mod.content.matches("encode_view_body(").count(),
4575            2,
4576            "impl pair inlined into the package file: {}",
4577            pkg_mod.content
4578        );
4579    }
4580
4581    #[test]
4582    fn generate_files_all_messages_file_per_package_inlines_serviceless_impls() {
4583        // Unified path + file_per_package: the service-less proto's impls
4584        // must be inlined into buffa's `<dotted.pkg>.rs` PackageMod (this
4585        // is the first flow that reaches the inlining helper with a
4586        // package that has no services).
4587        let file = FileDescriptorProto {
4588            name: Some("common.proto".into()),
4589            package: Some("common.v1".into()),
4590            message_type: vec![DescriptorProto {
4591                name: Some("Shared".into()),
4592                ..Default::default()
4593            }],
4594            ..Default::default()
4595        };
4596        let mut options = Options {
4597            encodable_impls: EncodableImpls::AllMessages,
4598            ..Options::default()
4599        };
4600        options.buffa.file_per_package = true;
4601        let generated = generate_files(
4602            std::slice::from_ref(&file),
4603            &["common.proto".into()],
4604            &options,
4605        )
4606        .unwrap();
4607        assert!(
4608            !generated
4609                .iter()
4610                .any(|f| f.kind == GeneratedFileKind::Companion),
4611            "file_per_package must not leave sibling Companion files"
4612        );
4613        let pkg_mod = generated
4614            .iter()
4615            .find(|f| f.kind == GeneratedFileKind::PackageMod && f.package == "common.v1")
4616            .expect("PackageMod for common.v1");
4617        assert_eq!(
4618            pkg_mod.content.matches("encode_view_body(").count(),
4619            2,
4620            "impl pair inlined into the package file: {}",
4621            pkg_mod.content
4622        );
4623    }
4624
4625    #[test]
4626    fn plugin_accepts_encodable_impls_option() {
4627        let file = FileDescriptorProto {
4628            name: Some("common.proto".into()),
4629            package: Some("common.v1".into()),
4630            message_type: vec![DescriptorProto {
4631                name: Some("Shared".into()),
4632                ..Default::default()
4633            }],
4634            ..Default::default()
4635        };
4636        let request = CodeGeneratorRequest {
4637            parameter: Some("buffa_module=crate::proto,encodable_impls=all_messages".into()),
4638            file_to_generate: vec!["common.proto".into()],
4639            proto_file: vec![file],
4640            ..Default::default()
4641        };
4642        let response = generate(&request).expect("encodable_impls=all_messages is recognized");
4643        let companion = response
4644            .file
4645            .iter()
4646            .find(|f| f.name.as_deref() == Some("common.__connect.rs"))
4647            .expect("plugin should emit impls for a service-less proto");
4648        assert!(
4649            companion
4650                .content
4651                .as_deref()
4652                .unwrap_or_default()
4653                .contains("impl ::connectrpc::Encodable<"),
4654        );
4655    }
4656
4657    #[test]
4658    fn plugin_rejects_invalid_encodable_impls_value() {
4659        let request = CodeGeneratorRequest {
4660            parameter: Some("buffa_module=crate::proto,encodable_impls=bogus".into()),
4661            file_to_generate: vec![],
4662            proto_file: vec![],
4663            ..Default::default()
4664        };
4665        let err = generate(&request).expect_err("bogus encodable_impls value must be rejected");
4666        let msg = err.to_string();
4667        assert!(
4668            msg.contains("invalid encodable_impls value"),
4669            "error should describe the bad value: {msg}"
4670        );
4671    }
4672
4673    #[test]
4674    fn generate_files_all_messages_wires_serviceless_companion() {
4675        // Unified path: the message-only proto's companion must be wired
4676        // into its package stitcher like any service companion.
4677        let file = FileDescriptorProto {
4678            name: Some("common.proto".into()),
4679            package: Some("common.v1".into()),
4680            message_type: vec![DescriptorProto {
4681                name: Some("Shared".into()),
4682                ..Default::default()
4683            }],
4684            ..Default::default()
4685        };
4686        let options = Options {
4687            encodable_impls: EncodableImpls::AllMessages,
4688            ..Options::default()
4689        };
4690        let generated = generate_files(
4691            std::slice::from_ref(&file),
4692            &["common.proto".into()],
4693            &options,
4694        )
4695        .unwrap();
4696        let companion = generated
4697            .iter()
4698            .find(|f| f.kind == GeneratedFileKind::Companion)
4699            .expect("companion for service-less proto in unified mode");
4700        assert_eq!(
4701            companion
4702                .content
4703                .matches("impl ::connectrpc::Encodable<")
4704                .count(),
4705            2
4706        );
4707        let stitcher = generated
4708            .iter()
4709            .find(|f| f.kind == GeneratedFileKind::PackageMod && f.package == "common.v1")
4710            .expect("package stitcher");
4711        assert!(
4712            stitcher
4713                .content
4714                .contains(&format!("include!(\"{}\")", companion.name)),
4715            "stitcher must include the companion: {}",
4716            stitcher.content
4717        );
4718    }
4719
4720    #[test]
4721    fn plugin_rejects_old_client_feature_value_form() {
4722        // The previous design used `client_feature=<name>` with an
4723        // arbitrary feature name. That option was renamed to the bare
4724        // flag `gate_client_feature`, and custom names now use
4725        // `gate_client_feature=<name>`. A stale buf.gen.yaml using the old form must fail
4726        // loudly, not silently no-op.
4727        let request = CodeGeneratorRequest {
4728            parameter: Some("buffa_module=crate::proto,client_feature=client".into()),
4729            file_to_generate: vec![],
4730            proto_file: vec![],
4731            ..Default::default()
4732        };
4733        let err = generate(&request)
4734            .expect_err("legacy `client_feature=…` option must now fail as unknown");
4735        let msg = err.to_string();
4736        assert!(
4737            msg.contains("client_feature"),
4738            "error should name the offending option: {msg}"
4739        );
4740        assert!(
4741            msg.contains("unknown plugin option"),
4742            "error should say the option is unknown: {msg}"
4743        );
4744    }
4745
4746    /// `element_memory_limit` is read off the wire before the request is
4747    /// decoded, so by the time options are parsed it is a leftover. Rejecting
4748    /// it would fail the build of the only person who ever sets it — someone
4749    /// whose schema was too large to decode without it.
4750    #[test]
4751    fn plugin_accepts_the_element_memory_limit_option_it_consumed_pre_decode() {
4752        for value in ["unlimited", "max", "2147483648"] {
4753            let request = CodeGeneratorRequest {
4754                parameter: Some(format!(
4755                    "buffa_module=crate::proto,{}={value}",
4756                    buffa_codegen::ELEMENT_MEMORY_LIMIT_OPT
4757                )),
4758                file_to_generate: vec![],
4759                proto_file: vec![],
4760                ..Default::default()
4761            };
4762            generate(&request).unwrap_or_else(|e| {
4763                panic!("element_memory_limit={value} must not reach the unknown-option arm: {e}")
4764            });
4765        }
4766    }
4767
4768    #[test]
4769    fn plugin_file_per_package_collapses_output() {
4770        // End-to-end through the protoc entry point: one `<dotted.pkg>.rs`
4771        // per package, no `<stem>.__connect.rs`, no `<pkg>.mod.rs`.
4772        let request = CodeGeneratorRequest {
4773            parameter: Some("buffa_module=crate::proto,file_per_package".into()),
4774            file_to_generate: vec!["a/x.proto".into(), "a/y.proto".into(), "b/z.proto".into()],
4775            proto_file: file_per_package_fixture(),
4776            ..Default::default()
4777        };
4778        let response = generate(&request).expect("file_per_package should parse and generate");
4779        let mut names: Vec<&str> = response
4780            .file
4781            .iter()
4782            .filter_map(|f| f.name.as_deref())
4783            .collect();
4784        names.sort_unstable();
4785        assert_eq!(
4786            names,
4787            ["a.v1.rs", "b.v1.rs"],
4788            "expected one file per package: {names:?}"
4789        );
4790        for f in &response.file {
4791            let content = f.content.as_deref().unwrap_or_default();
4792            assert!(
4793                !content.contains("include!"),
4794                "file_per_package output must be self-contained: {content}"
4795            );
4796        }
4797    }
4798
4799    #[test]
4800    fn no_top_level_use_statements_in_generated_code() {
4801        // When multiple service files are `include!`d into the same module,
4802        // top-level `use` statements cause E0252 (duplicate imports). Verify
4803        // the generated code uses fully qualified paths instead.
4804        let file = minimal_file(
4805            Some("example.v1"),
4806            ".example.v1.PingReq",
4807            ".example.v1.PingResp",
4808            &["PingReq", "PingResp"],
4809        );
4810        let code = gen_service(std::slice::from_ref(&file), 0, &[], false).unwrap();
4811        let formatted = format_token_stream(&code.parse::<TokenStream>().unwrap()).unwrap();
4812        assert_no_top_level_use(&formatted, "generated code");
4813    }
4814
4815    #[test]
4816    fn multi_service_include_no_e0252() {
4817        // Simulate `buffa-packaging` including two service files into one
4818        // module. Both files must parse together without duplicate imports.
4819        let file_a = {
4820            let method = MethodDescriptorProto {
4821                name: Some("Ping".into()),
4822                input_type: Some(".svc.v1.PingReq".into()),
4823                output_type: Some(".svc.v1.PingResp".into()),
4824                ..Default::default()
4825            };
4826            let service = ServiceDescriptorProto {
4827                name: Some("Alpha".into()),
4828                method: vec![method],
4829                ..Default::default()
4830            };
4831            FileDescriptorProto {
4832                name: Some("alpha.proto".into()),
4833                package: Some("svc.v1".into()),
4834                service: vec![service],
4835                message_type: vec![
4836                    DescriptorProto {
4837                        name: Some("PingReq".into()),
4838                        ..Default::default()
4839                    },
4840                    DescriptorProto {
4841                        name: Some("PingResp".into()),
4842                        ..Default::default()
4843                    },
4844                ],
4845                ..Default::default()
4846            }
4847        };
4848        let file_b = {
4849            let method = MethodDescriptorProto {
4850                name: Some("Pong".into()),
4851                input_type: Some(".svc.v1.PongReq".into()),
4852                output_type: Some(".svc.v1.PongResp".into()),
4853                ..Default::default()
4854            };
4855            let service = ServiceDescriptorProto {
4856                name: Some("Beta".into()),
4857                method: vec![method],
4858                ..Default::default()
4859            };
4860            FileDescriptorProto {
4861                name: Some("beta.proto".into()),
4862                package: Some("svc.v1".into()),
4863                service: vec![service],
4864                message_type: vec![
4865                    DescriptorProto {
4866                        name: Some("PongReq".into()),
4867                        ..Default::default()
4868                    },
4869                    DescriptorProto {
4870                        name: Some("PongResp".into()),
4871                        ..Default::default()
4872                    },
4873                ],
4874                ..Default::default()
4875            }
4876        };
4877
4878        let files = vec![file_a, file_b];
4879        let config = buffa_codegen::CodeGenConfig::default();
4880        let targets = vec!["alpha.proto".to_string(), "beta.proto".to_string()];
4881        let resolver = TypeResolver::new(&files, &targets, &config, false);
4882
4883        let mut batch = BatchState {
4884            colliding_aliases: collect_alias_collisions(&files, &targets),
4885            ..BatchState::default()
4886        };
4887        let code_a = generate_connect_services(&files[0], &resolver, &mut batch).unwrap();
4888        let code_b = generate_connect_services(&files[1], &resolver, &mut batch).unwrap();
4889
4890        let formatted_a = format_token_stream(&code_a).unwrap();
4891        let formatted_b = format_token_stream(&code_b).unwrap();
4892
4893        // Each file independently must parse.
4894        syn::parse_str::<syn::File>(&formatted_a).expect("service A should parse independently");
4895        syn::parse_str::<syn::File>(&formatted_b).expect("service B should parse independently");
4896
4897        // Both files combined into one module must also parse (the E0252 scenario).
4898        let combined = format!("{formatted_a}\n{formatted_b}");
4899        syn::parse_str::<syn::File>(&combined)
4900            .expect("combined services should parse without E0252");
4901
4902        // No top-level `use` in either file.
4903        assert_no_top_level_use(&formatted_a, "service A");
4904        assert_no_top_level_use(&formatted_b, "service B");
4905    }
4906
4907    /// `generate_spec_consts` emits one `pub const … : Spec` per method,
4908    /// named `{SERVICE}_{METHOD}_SPEC`, with the right `StreamType`,
4909    /// `IdempotencyLevel`, and procedure path.
4910    #[test]
4911    fn generate_spec_consts_per_method() {
4912        use buffa_codegen::generated::descriptor::MethodOptions;
4913
4914        let m = |name: &str, cs: bool, ss: bool, idem: Option<IdempotencyLevel>| {
4915            MethodDescriptorProto {
4916                name: Some(name.into()),
4917                input_type: Some(".pkg.Req".into()),
4918                output_type: Some(".pkg.Resp".into()),
4919                client_streaming: Some(cs),
4920                server_streaming: Some(ss),
4921                options: MethodOptions {
4922                    idempotency_level: idem,
4923                    ..Default::default()
4924                }
4925                .into(),
4926                ..Default::default()
4927            }
4928        };
4929        let service = ServiceDescriptorProto {
4930            name: Some("EchoService".into()),
4931            method: vec![
4932                m("Say", false, false, Some(IdempotencyLevel::NO_SIDE_EFFECTS)),
4933                m("Subscribe", false, true, Some(IdempotencyLevel::IDEMPOTENT)),
4934                m("Upload", true, false, None),
4935                m("Chat", true, true, None),
4936            ],
4937            ..Default::default()
4938        };
4939
4940        // The const names follow `{SERVICE}_{METHOD}_SPEC`.
4941        assert_eq!(
4942            method_spec_const_ident(&service, "Say").to_string(),
4943            "ECHO_SERVICE_SAY_SPEC"
4944        );
4945
4946        let consts = generate_spec_consts("pkg.EchoService", &service);
4947        assert_eq!(consts.len(), 4, "one const per method");
4948
4949        let render = |ts: &TokenStream| {
4950            let file = syn::parse2::<syn::File>(ts.clone()).expect("const should parse");
4951            prettyplease::unparse(&file)
4952        };
4953        let say = render(&consts[0]);
4954        assert!(say.contains("pub const ECHO_SERVICE_SAY_SPEC"), "{say}");
4955        assert!(say.contains(r#""/pkg.EchoService/Say""#), "{say}");
4956        assert!(say.contains("StreamType::Unary"), "{say}");
4957        assert!(say.contains("IdempotencyLevel::NoSideEffects"), "{say}");
4958        // One constant per method: no client sibling is emitted.
4959        assert!(!say.contains("CLIENT_SPEC"), "{say}");
4960        assert!(!say.contains("Spec::client("), "{say}");
4961
4962        let subscribe = render(&consts[1]);
4963        assert!(
4964            subscribe.contains("StreamType::ServerStream"),
4965            "{subscribe}"
4966        );
4967        assert!(
4968            subscribe.contains("IdempotencyLevel::Idempotent"),
4969            "{subscribe}"
4970        );
4971
4972        let upload = render(&consts[2]);
4973        assert!(upload.contains("StreamType::ClientStream"), "{upload}");
4974        assert!(upload.contains("IdempotencyLevel::Unknown"), "{upload}");
4975
4976        let chat = render(&consts[3]);
4977        assert!(chat.contains("StreamType::BidiStream"), "{chat}");
4978    }
4979
4980    /// `Get` + `GetSpec` do not collide: their constants are `X_GET_SPEC` and
4981    /// `X_GET_SPEC_SPEC`, and `get_spec` is only ever a client method name.
4982    /// Methods named `Client` and `Spec` are ordinary too.
4983    #[test]
4984    fn spec_const_names_do_not_collide_with_method_names() {
4985        let m = |name: &str| MethodDescriptorProto {
4986            name: Some(name.into()),
4987            input_type: Some(".pkg.Req".into()),
4988            output_type: Some(".pkg.Resp".into()),
4989            ..Default::default()
4990        };
4991        let service = ServiceDescriptorProto {
4992            name: Some("X".into()),
4993            method: vec![m("Get"), m("GetSpec"), m("Put"), m("Client"), m("Spec")],
4994            ..Default::default()
4995        };
4996        check_method_collisions("X", &service).unwrap();
4997    }
4998
4999    /// Generated client methods identify the RPC to the runtime by the
5000    /// module-scope `*_SPEC` constant with `origin` flipped to `Client`, not
5001    /// by service/method strings.
5002    #[test]
5003    fn client_methods_pass_spec_const_as_client() {
5004        let out = format_minimal_service(false);
5005        assert!(
5006            !out.contains("CLIENT_SPEC"),
5007            "no client sibling const: {out}"
5008        );
5009        // The unary call site: transport, config, then the const as client.
5010        let call = out
5011            .find("::connectrpc::client::call_unary(")
5012            .expect("minimal service has a unary client method");
5013        let window = &out[call..(call + 240).min(out.len())];
5014        assert!(
5015            window.contains("PING_SERVICE_PING_SPEC.with_origin(::connectrpc::SpecOrigin::Client)"),
5016            "call_unary must receive the Spec const with client origin:\n{window}"
5017        );
5018        assert!(
5019            !window.contains("PING_SERVICE_SERVICE_NAME"),
5020            "the (service, method) string pair is gone from client call sites:\n{window}"
5021        );
5022    }
5023
5024    #[test]
5025    fn declares_edition_2024_support() {
5026        // protoc refuses to run a generator against a file whose edition
5027        // falls outside the advertised range, so this declaration is the
5028        // whole of edition support for a service generator.
5029        let response = generate(&CodeGeneratorRequest::default())
5030            .expect("an empty request still yields a response carrying the edition range");
5031        assert_eq!(response.minimum_edition, Some(Edition::EDITION_2023 as i32));
5032        assert_eq!(response.maximum_edition, Some(Edition::EDITION_2024 as i32));
5033    }
5034}