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