Skip to main content

olai_codegen/analysis/
types.rs

1use std::collections::HashSet;
2
3use convert_case::{Case, Casing};
4use quote::format_ident;
5use syn::Ident;
6
7use crate::error::{Error, Result};
8use crate::google::api::{ResourceDescriptor, http_rule::Pattern};
9use crate::parsing::types::UnifiedType;
10use crate::parsing::{CodeGenMetadata, HttpPattern, MethodMetadata, OneofVariant};
11
12/// The Operation a method is performing
13///
14/// There are standard CRUD operations, as well as custom operations.
15///
16/// Standard operations on collections are:
17/// - List: Retrieve a list of resources
18/// - Create: Create a new resource
19///
20/// Standard operations on individual resources are:
21/// - Get: Retrieve a single resource
22/// - Update: Update an existing resource
23/// - Delete: Delete a resource
24///
25/// Custom operations are:
26/// - Custom(Pattern): custom HTTP operation
27#[derive(Debug, Clone, PartialEq)]
28pub enum RequestType {
29    List,
30    Create,
31    Get,
32    Update,
33    Delete,
34    Custom(Pattern),
35}
36
37/// Where a method sits relative to a resource, independent of its CRUD verb.
38///
39/// Computed once during analysis from the method's [`RequestType`], name, and whether the owning
40/// service manages a `google.api.resource`. This makes the scoped/flat and collection/instance
41/// decisions explicit on the IR so emitters stop re-deriving them (replacing the old
42/// `MethodHandler::is_collection_method()` + `BindingMode` logic scattered across emit sites).
43///
44/// The three cases map onto the binding layout:
45/// - [`MethodShape::CollectionScoped`] and [`MethodShape::InstanceScoped`] both belong to a
46///   resource-scoped service; collection methods live on the root client, instance methods on the
47///   per-resource scoped client.
48/// - [`MethodShape::Unbound`] belongs to a resource-less service; every method lives on the root
49///   client and passes all params (the old "flat" lowering).
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum MethodShape {
52    /// Collection-style operation on a resource service (list / create / factory RPC). Lives on the
53    /// root/aggregate client and passes every param.
54    CollectionScoped,
55    /// Instance operation on a resource service (get / update / delete / resource-targeted custom).
56    /// Lives on the per-resource scoped client, which already holds the path params.
57    InstanceScoped,
58    /// Method of a resource-less ("flat") service. Lives on the root client and passes every param,
59    /// including path params.
60    Unbound,
61}
62
63/// The structural template a language binding uses to emit a method, independent of language.
64///
65/// Collapses the per-binding `match request_type { … }` dispatch (which Python, Node, and
66/// TypeScript each repeated identically) into a single analysis decision. Each variant corresponds
67/// to one emit-shape helper every binding already has (`*_list_method_impl`,
68/// `*_create_method_impl`, `*_get_update_method_impl`, `*_delete_method_impl`).
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum EmitShape {
71    /// Streamed/collected list — returns the repeated item type. (`List`, or a custom `GET` named
72    /// `List*`.)
73    List,
74    /// Factory/create — body in, returns a (possibly unrelated) response message. (`Create`, or a
75    /// collection-style custom `POST`/`PATCH` factory like `Generate*`.)
76    Create,
77    /// Get-or-update — path params (+ optional body), returns the resource/response. (`Get`,
78    /// `Update`, a resource-targeted custom `POST`/`PATCH`, or any other custom verb on a flat
79    /// service.)
80    GetUpdate,
81    /// Delete — path params, no response. (`Delete`.)
82    Delete,
83}
84
85/// Classify a method's [`EmitShape`] from its request type and proto name.
86///
87/// Mirrors **exactly** the per-impl dispatch the bindings previously each performed inline (the
88/// `match request_type` arms in `collection_client_method` / `flat_client_method` /
89/// `resource_client_method`), so emit shape is decided in one place without behavior drift.
90///
91/// `is_collection` (from [`is_collection_method`]) only distinguishes a collection-style custom
92/// `POST`/`PATCH` factory (→ [`EmitShape::Create`]) from a resource-targeted custom `POST`/`PATCH`
93/// (→ [`EmitShape::GetUpdate`]). Note a resource-less `Custom(Get)` named `List*` is **not**
94/// List-shaped here: the old flat dispatch routed it through the `Custom(_) => get_update` arm
95/// (returning the full response message, keeping `page_token`), and only a true
96/// [`RequestType::List`] gets streamed/`List` emit. Collection *routing* (which client a method
97/// lands on) is a separate decision driven by [`is_collection_method`].
98pub(crate) fn emit_shape(request_type: &RequestType, is_collection: bool) -> EmitShape {
99    match request_type {
100        RequestType::List => EmitShape::List,
101        RequestType::Create => EmitShape::Create,
102        RequestType::Delete => EmitShape::Delete,
103        RequestType::Get | RequestType::Update => EmitShape::GetUpdate,
104        // A collection-style custom POST/PATCH is a factory (Create-shaped); every other custom verb
105        // — including a `Custom(Get)` named `List*` — is GetUpdate-shaped (path + optional body,
106        // return the full response).
107        RequestType::Custom(Pattern::Post(_) | Pattern::Patch(_)) if is_collection => {
108            EmitShape::Create
109        }
110        RequestType::Custom(_) => EmitShape::GetUpdate,
111    }
112}
113
114/// A method that was skipped during analysis due to incomplete metadata.
115#[derive(Debug, Clone)]
116pub struct SkippedMethod {
117    /// Fully-qualified name of the service containing the skipped method.
118    pub service_name: String,
119    /// Name of the skipped method (e.g. `"GetCatalog"`).
120    pub method_name: String,
121    /// Human-readable reason the method was skipped (e.g. `"missing HTTP annotation"`).
122    pub reason: String,
123}
124
125/// High-level plan for what code to generate
126#[derive(Debug)]
127pub struct GenerationPlan {
128    /// Services to generate handlers for
129    pub services: Vec<ServicePlan>,
130    /// Methods that were excluded from the plan due to incomplete metadata.
131    ///
132    /// Callers can inspect this list to distinguish "service has zero methods" from "all methods
133    /// were skipped due to missing HTTP annotations", and to surface actionable warnings.
134    pub skipped_methods: Vec<SkippedMethod>,
135}
136
137/// Plan for generating code for a single service
138#[derive(Debug, Clone)]
139pub struct ServicePlan {
140    /// Service name (e.g., "CatalogsService")
141    pub service_name: String,
142    /// Handler trait name (e.g., "CatalogHandler")
143    pub handler_name: String,
144    /// Base URL path for this service (e.g., "catalogs")
145    pub base_path: String,
146    /// Proto package name (e.g., "unitycatalog.catalogs.v1")
147    pub package: String,
148    /// Methods to generate for this service
149    pub methods: Vec<MethodPlan>,
150    /// Resources managed by this service
151    pub managed_resources: Vec<ManagedResource>,
152    /// Documentation from protobuf service comments
153    pub documentation: Option<String>,
154    /// Ancestor chain for this service's managed resource, derived cross-service from
155    /// `resource_reference { child_type }` annotations.
156    ///
157    /// Entries are ordered **root-first** (shallowest ancestor first). For example, for
158    /// a Table service this would be `[catalog_name (depth 0), schema_name (depth 1)]`.
159    ///
160    /// Empty when no `resource_reference` annotations are present — codegen falls back to
161    /// naming heuristics in that case.
162    pub hierarchy: Vec<ResourceHierarchy>,
163    /// Parameter names for this service's resource accessor on the aggregate client, e.g.
164    /// `["catalog_name", "schema_name"]` for a nested Schema or `["name"]` for a flat Catalog.
165    ///
166    /// The trailing element is the resource's own `<singular>_name`; any preceding elements are
167    /// ancestor identifiers. A list of length > 1 means the resource is *nested* and its full name
168    /// is the dot-joined components.
169    ///
170    /// `None` for resource-less services (no accessor is generated). Computed once during analysis
171    /// (Phase 2, after `hierarchy` is populated) so the four accessor emitters — Rust aggregate,
172    /// Python, Node, TypeScript — all read the same list instead of each re-deriving it.
173    pub resource_accessor_params: Option<Vec<String>>,
174    /// Direct child resources of this service's managed resource, used to generate child-navigation
175    /// accessors (e.g. `catalog.schema(name)`) and child-create methods (e.g. `catalog.create_schema`)
176    /// on the scoped client.
177    ///
178    /// A resource C is a *direct child* of P when C's [`Self::resource_accessor_params`] equals P's
179    /// plus exactly one additional trailing component (P's params are a prefix of C's, and
180    /// `C.len() == P.len() + 1`). Sorted by `child_singular`. Empty for leaf or flat resources.
181    /// Computed in Phase 2 of `analyze_metadata` after every service's accessor params are known.
182    pub direct_children: Vec<ChildLink>,
183}
184
185/// A direct child resource of a parent resource, for generating child-navigation and child-create
186/// methods on the parent's scoped client. See [`ServicePlan::direct_children`].
187#[derive(Debug, Clone)]
188pub struct ChildLink {
189    /// The child resource singular — used as the navigation accessor method name (e.g. `schema`).
190    pub child_singular: String,
191    /// The child service's `base_path` / module segment (e.g. `schemas`), used to reference the
192    /// child's generated clients as `crate::codegen::<base_path>::…`.
193    pub child_base_path: String,
194    /// The child's full accessor params (ancestors + own leaf), e.g. `["catalog_name","schema_name"]`.
195    /// The leading entries equal the parent's accessor params (the prefix relation); the final entry
196    /// is the child's own name component.
197    pub child_accessor_params: Vec<String>,
198}
199
200/// Plan for generating code for a single method
201#[derive(Debug, Clone)]
202pub struct MethodPlan {
203    /// Original method metadata
204    pub metadata: MethodMetadata,
205    /// Rust function name for the handler method
206    pub handler_function_name: String,
207    /// Pre-parsed HTTP URL pattern
208    pub http_pattern: HttpPattern,
209    /// HTTP method string for routing (e.g., "GET", "POST")
210    pub http_method: String,
211    /// Parameters passed to the method (path, query, and body)
212    pub parameters: Vec<RequestParam>,
213    /// Whether this method returns a response body
214    pub has_response: bool,
215    /// Request type for this method
216    pub request_type: RequestType,
217    /// The resource type name returned by this method (if any)
218    pub output_resource_type: Option<String>,
219    /// Whether the HTTP request carries a JSON body (create / update / custom POST·PATCH).
220    ///
221    /// Derived once from [`RequestType`] so the client emitter doesn't re-match the enum to
222    /// decide whether to attach `.json(request)`.
223    pub has_request_body: bool,
224    /// Whether the Axum server extractor reads from request *parts* (path/query only) rather
225    /// than the body — i.e. this is a `FromRequestParts` method, not `FromRequest`.
226    ///
227    /// Derived once from [`RequestType`] so the server emitter (extractor selection and the
228    /// `RequestPartsExt` import) doesn't re-match the enum.
229    pub needs_request_parts: bool,
230    /// The verb name to call on a resource-scoped client (`get` / `update` / `delete`), or
231    /// `None` for methods that are not standard instance verbs.
232    ///
233    /// Derived once from [`RequestType`]; the scoped binding call uses this instead of
234    /// re-deciding the verb from the enum (see `MethodPlan::resource_client_method`).
235    pub scoped_verb: Option<String>,
236    /// Where this method sits relative to a resource (collection / instance / unbound).
237    ///
238    /// Assigned in `analyze_service` once the owning service's managed resources are known, so it
239    /// reflects both the method's verb/name shape and whether the service is resource-scoped.
240    pub shape: MethodShape,
241    /// Whether this method has a `google.api.http` route.
242    ///
243    /// Analysis is protocol-agnostic: every method gets a plan. A method without an HTTP
244    /// annotation still produces a plan (with all request fields as body, no path/query) so the
245    /// ConnectRPC emitter can use it — but it has no REST route, so the REST client/handler
246    /// emitters skip it. `true` for methods with an HTTP annotation, `false` otherwise.
247    pub has_http_route: bool,
248}
249
250impl MethodPlan {
251    pub fn path_parameters(&self) -> impl Iterator<Item = &PathParam> {
252        self.parameters.iter().filter_map(|param| match param {
253            RequestParam::Path(path_param) => Some(path_param),
254            _ => None,
255        })
256    }
257
258    pub fn query_parameters(&self) -> impl Iterator<Item = &QueryParam> {
259        self.parameters.iter().filter_map(|param| match param {
260            RequestParam::Query(query_param) => Some(query_param),
261            _ => None,
262        })
263    }
264
265    pub fn body_fields(&self) -> impl Iterator<Item = &BodyField> {
266        self.parameters.iter().filter_map(|param| match param {
267            RequestParam::Body(body_field) => Some(body_field),
268            _ => None,
269        })
270    }
271}
272
273#[derive(Debug, Clone)]
274pub enum RequestParam {
275    Path(PathParam),
276    Query(QueryParam),
277    Body(BodyField),
278}
279
280impl RequestParam {
281    pub fn name(&self) -> &str {
282        match self {
283            RequestParam::Path(param) => &param.name,
284            RequestParam::Query(param) => &param.name,
285            RequestParam::Body(param) => &param.name,
286        }
287    }
288
289    pub fn field_type(&self) -> &UnifiedType {
290        match self {
291            RequestParam::Path(param) => &param.field_type,
292            RequestParam::Query(param) => &param.field_type,
293            RequestParam::Body(param) => &param.field_type,
294        }
295    }
296
297    pub fn field_ident(&self) -> Ident {
298        format_ident!("{}", self.name())
299    }
300
301    pub fn is_optional(&self) -> bool {
302        match self {
303            RequestParam::Path(_) => false,
304            RequestParam::Query(param) => param.is_optional(),
305            RequestParam::Body(param) => param.is_optional(),
306        }
307    }
308
309    pub fn is_path_param(&self) -> bool {
310        matches!(self, RequestParam::Path(_))
311    }
312
313    pub fn documentation(&self) -> Option<&str> {
314        match self {
315            RequestParam::Path(param) => param.documentation.as_deref(),
316            RequestParam::Query(param) => param.documentation.as_deref(),
317            RequestParam::Body(param) => param.documentation.as_deref(),
318        }
319    }
320}
321
322/// A path parameter in a URL template
323#[derive(Debug, Clone)]
324pub struct PathParam {
325    /// Field name in the request struct (e.g., "full_name")
326    pub name: String,
327    /// Parsed type of the path parameter
328    pub field_type: UnifiedType,
329    /// Documentation from protobuf field comments
330    pub documentation: Option<String>,
331}
332
333impl From<PathParam> for RequestParam {
334    fn from(param: PathParam) -> Self {
335        RequestParam::Path(param)
336    }
337}
338
339/// A query parameter for HTTP requests
340#[derive(Debug, Clone)]
341pub struct QueryParam {
342    /// Parameter name
343    pub name: String,
344    /// Parsed type of the query parameter
345    pub field_type: UnifiedType,
346    /// Documentation from protobuf field comments
347    pub documentation: Option<String>,
348    /// Resource reference annotation, if present on the corresponding proto field.
349    ///
350    /// - `child_type` non-empty: this param scopes a parent of that resource type
351    ///   (e.g. `catalog_name` with `child_type = "unitycatalog.io/Schema"`).
352    /// - `r#type` non-empty: this param directly identifies a resource of that type.
353    pub resource_reference: Option<crate::google::api::ResourceReference>,
354}
355
356impl QueryParam {
357    /// Denotes if the parameter is optional
358    pub fn is_optional(&self) -> bool {
359        self.field_type.is_optional
360    }
361}
362
363impl From<QueryParam> for RequestParam {
364    fn from(param: QueryParam) -> Self {
365        RequestParam::Query(param)
366    }
367}
368
369/// A body field that should be extracted from the request body
370#[derive(Debug, Clone)]
371pub struct BodyField {
372    /// Field name
373    pub name: String,
374    /// Parsed type of the body parameter
375    pub field_type: UnifiedType,
376    /// Whether this field is a repeated (Vec) type
377    pub repeated: bool,
378    /// Whether the field is explicitly marked `(google.api.field_behavior) = REQUIRED`.
379    pub required: bool,
380    /// For oneof fields, the variants with their names and types
381    pub oneof_variants: Option<Vec<OneofVariant>>,
382    /// Documentation from protobuf field comments
383    pub documentation: Option<String>,
384}
385
386impl BodyField {
387    /// Denotes whether this field should be treated as optional in builder APIs.
388    ///
389    /// A field marked `REQUIRED` is never optional — even for complex types — so it becomes a
390    /// required constructor parameter rather than a `with_*` setter. (Without this, a required
391    /// message body like `tag_assignment` would be a no-arg-constructor + optional setter, and the
392    /// NAPI binding would execute with an empty body.)
393    ///
394    /// Otherwise a field is optional when its `UnifiedType.is_optional` flag is set, when it is
395    /// repeated, or when its base type is `Map`, `Message`, or `OneOf` (complex types have a valid
396    /// default and are treated as optional constructor parameters).
397    pub fn is_optional(&self) -> bool {
398        use crate::parsing::types::BaseType;
399        if self.required {
400            return false;
401        }
402        self.field_type.is_optional
403            || self.repeated
404            || matches!(
405                self.field_type.base_type,
406                BaseType::Map(_, _) | BaseType::Message(_) | BaseType::OneOf(_)
407            )
408    }
409}
410
411impl From<BodyField> for RequestParam {
412    fn from(field: BodyField) -> Self {
413        RequestParam::Body(field)
414    }
415}
416
417/// Information about a resource managed by a service
418#[derive(Debug, Clone)]
419pub struct ManagedResource {
420    /// Resource type name (e.g., "Catalog")
421    pub type_name: String,
422    /// Resource descriptor information
423    pub descriptor: ResourceDescriptor,
424}
425
426/// Describes one ancestor step in a managed resource's parent chain, derived from
427/// `google.api.resource_reference { child_type }` annotations on List request fields.
428///
429/// Entries in [`ServicePlan::hierarchy`] are ordered **root-first** (shallowest ancestor first),
430/// so iterating them in order produces the correct param list for resource accessors (e.g.
431/// `["catalog_name", "schema_name"]` for a Table, where catalog is depth 0 and schema depth 1).
432///
433/// Built during analysis via the cross-service global parent map and stored on [`ServicePlan`].
434#[derive(Debug, Clone)]
435#[non_exhaustive]
436pub struct ResourceHierarchy {
437    /// The service's managed resource type string (e.g. `"unitycatalog.io/Table"`).
438    ///
439    /// Note: on flat APIs this equals the `child_type` annotation value, but the *actual*
440    /// resource type of the ancestor is `parent_resource_type`, which may differ.
441    pub child_resource_type: String,
442    /// The actual resource type of the ancestor identified by `parent_field_name`
443    /// (e.g. `"unitycatalog.io/Catalog"` for the `catalog_name` field on ListTablesRequest).
444    ///
445    /// This may differ from `child_resource_type` for grandparent fields on flat APIs
446    /// (e.g. `catalog_name` on ListTablesRequest has `child_type = Table` but the field
447    /// actually identifies a Catalog resource).
448    pub parent_resource_type: String,
449    /// The proto field name carrying the ancestor identifier (e.g. `"catalog_name"`).
450    pub parent_field_name: String,
451    /// The singular name of the ancestor resource (e.g. `"catalog"`), resolved by stripping
452    /// `"_name"` from `parent_field_name` and matching against known resource descriptors.
453    /// `None` when the singular cannot be resolved.
454    pub parent_singular: Option<String>,
455}
456
457/// Classifies an RPC method as a standard CRUD operation or custom operation.
458///
459/// This is an internal helper used by [`super::analyze_method`]. Construct via
460/// [`MethodPlanner::try_new`] and consume with [`MethodPlanner::request_type`] and
461/// [`MethodPlanner::http_pattern`].
462pub(crate) struct MethodPlanner<'a> {
463    method: &'a MethodMetadata,
464    pattern: Pattern,
465    path: HttpPattern,
466    metadata: &'a CodeGenMetadata,
467}
468
469impl<'a> MethodPlanner<'a> {
470    pub(crate) fn try_new(
471        method: &'a MethodMetadata,
472        metadata: &'a CodeGenMetadata,
473    ) -> Result<Self> {
474        let Some(pattern) = &method.http_rule.pattern else {
475            return Err(Error::MissingAnnotation {
476                object: method.method_name.clone(),
477                message: "Missing HTTP rule pattern".to_string(),
478            });
479        };
480        Ok(Self {
481            method,
482            path: method.http_pattern.clone(),
483            pattern: pattern.clone(),
484            metadata,
485        })
486    }
487
488    /// Consume the planner and return the pre-parsed HTTP URL pattern.
489    pub(crate) fn into_http_pattern(self) -> HttpPattern {
490        self.path
491    }
492
493    /// Classify the RPC as a standard CRUD operation per Google AIP 131-135.
494    ///
495    /// Each standard operation is identified by matching (verb, HTTP method, path shape,
496    /// resource lookup). See:
497    /// - [AIP-131](https://google.aip.dev/131) Get
498    /// - [AIP-132](https://google.aip.dev/132) List
499    /// - [AIP-133](https://google.aip.dev/133) Create
500    /// - [AIP-134](https://google.aip.dev/134) Update
501    /// - [AIP-135](https://google.aip.dev/135) Delete
502    pub(crate) fn request_type(&self) -> RequestType {
503        let snake_name = self.method.method_name.to_case(Case::Snake);
504        let verb_resource = snake_name.split_once('_');
505
506        if let Some((verb, resource)) = verb_resource {
507            // Table of (verb, expected pattern, path must end with parameter?, lookup by plural?)
508            #[allow(clippy::type_complexity)]
509            let standard_ops: &[(
510                &str,
511                fn(&Pattern) -> bool,
512                bool,
513                bool,
514                RequestType,
515            )] = &[
516                (
517                    "get",
518                    |p| matches!(p, Pattern::Get(_)),
519                    true,
520                    false,
521                    RequestType::Get,
522                ),
523                (
524                    "list",
525                    |p| matches!(p, Pattern::Get(_)),
526                    false,
527                    true,
528                    RequestType::List,
529                ),
530                (
531                    "create",
532                    |p| matches!(p, Pattern::Post(_)),
533                    false,
534                    false,
535                    RequestType::Create,
536                ),
537                (
538                    "update",
539                    |p| matches!(p, Pattern::Patch(_)),
540                    true,
541                    false,
542                    RequestType::Update,
543                ),
544                (
545                    "delete",
546                    |p| matches!(p, Pattern::Delete(_)),
547                    true,
548                    false,
549                    RequestType::Delete,
550                ),
551            ];
552
553            for &(expected_verb, pattern_check, ends_with_param, use_plural, ref result_type) in
554                standard_ops
555            {
556                if verb != expected_verb || !pattern_check(&self.pattern) {
557                    continue;
558                }
559                if ends_with_param && self.path.ends_with_static() {
560                    continue;
561                }
562                if !ends_with_param && self.path.ends_with_parameter() {
563                    continue;
564                }
565                let found = if use_plural {
566                    self.metadata.resource_from_plural(resource).is_some()
567                } else {
568                    self.metadata.resource_from_singular(resource).is_some()
569                };
570                if found {
571                    return result_type.clone();
572                }
573            }
574        }
575
576        RequestType::Custom(self.pattern.clone())
577    }
578
579    pub(crate) fn has_response(&self) -> bool {
580        !self.method.output_type.is_empty() && !self.method.output_type.ends_with("Empty")
581    }
582
583    /// Extract the simple resource type name from the method's output type.
584    ///
585    /// Strips the package prefix (e.g., `.example.catalog.v1.Catalog` → `Catalog`).
586    pub(crate) fn output_resource_type(&self) -> Option<String> {
587        if self.has_response() {
588            let output_type = &self.method.output_type;
589            let simple = output_type
590                .rfind('.')
591                .map(|i| &output_type[i + 1..])
592                .unwrap_or(output_type);
593            Some(simple.to_string())
594        } else {
595            None
596        }
597    }
598}
599
600/// Whether a request of this type carries a JSON body (create / update / custom POST·PATCH).
601///
602/// Single source of truth for the body-attach decision the client emitter previously re-derived.
603pub(crate) fn request_has_body(request_type: &RequestType) -> bool {
604    matches!(
605        request_type,
606        RequestType::Create
607            | RequestType::Update
608            | RequestType::Custom(Pattern::Post(_))
609            | RequestType::Custom(Pattern::Patch(_))
610    )
611}
612
613/// Whether the Axum server extractor reads request *parts* (path/query) rather than the body.
614///
615/// Single source of truth for the `FromRequestParts`-vs-`FromRequest` decision (and the
616/// `RequestPartsExt` import) the server emitter previously re-derived.
617pub(crate) fn request_needs_request_parts(request_type: &RequestType) -> bool {
618    matches!(
619        request_type,
620        RequestType::List | RequestType::Get | RequestType::Delete
621    ) || matches!(
622        request_type,
623        RequestType::Custom(Pattern::Get(_) | Pattern::Delete(_))
624    )
625}
626
627/// Whether a method is a collection-style operation (list / create / factory RPC).
628///
629/// Single source of truth for the collection/instance split. Standard `List`/`Create` always
630/// qualify; custom RPCs qualify by name convention — a `GET` named `List*` (a resource-less list)
631/// or a `POST` named `Generate*` (a factory RPC like `GenerateCredentials`). This is the only place
632/// the `List`/`Generate` proto-name heuristics live.
633pub(crate) fn is_collection_method(request_type: &RequestType, method_name: &str) -> bool {
634    matches!(request_type, RequestType::List | RequestType::Create)
635        || (matches!(request_type, RequestType::Custom(Pattern::Get(_)))
636            && method_name.starts_with("List"))
637        || (matches!(request_type, RequestType::Custom(Pattern::Post(_)))
638            && method_name.starts_with("Generate"))
639}
640
641/// Classify a method's [`MethodShape`] from its request type, proto name, and whether the owning
642/// service manages a `google.api.resource`.
643///
644/// - Resource-less service → [`MethodShape::Unbound`] for every method.
645/// - Resource service, collection-style method → [`MethodShape::CollectionScoped`].
646/// - Resource service, otherwise → [`MethodShape::InstanceScoped`].
647pub(crate) fn method_shape(
648    request_type: &RequestType,
649    method_name: &str,
650    service_has_resource: bool,
651) -> MethodShape {
652    if !service_has_resource {
653        MethodShape::Unbound
654    } else if is_collection_method(request_type, method_name) {
655        MethodShape::CollectionScoped
656    } else {
657        MethodShape::InstanceScoped
658    }
659}
660
661/// The verb to call on a resource-scoped client for standard instance operations, or `None`.
662///
663/// Single source of truth for the scoped-verb mapping the binding emitters previously re-derived.
664pub(crate) fn scoped_verb(request_type: &RequestType) -> Option<String> {
665    match request_type {
666        RequestType::Get => Some("get".to_string()),
667        RequestType::Update => Some("update".to_string()),
668        RequestType::Delete => Some("delete".to_string()),
669        _ => None,
670    }
671}
672
673/// Split body fields from a `MethodPlan` into required and optional subsets.
674///
675/// Delegates to [`BodyField::is_optional`] for the classification. Optional fields
676/// become `with_*` setter methods; required fields become constructor parameters.
677pub fn split_body_fields(plan: &MethodPlan) -> (Vec<&BodyField>, Vec<&BodyField>) {
678    let mut required = Vec::new();
679    let mut optional = Vec::new();
680    for field in plan.body_fields() {
681        if field.is_optional() {
682            optional.push(field);
683        } else {
684            required.push(field);
685        }
686    }
687    (required, optional)
688}
689
690/// Extract managed resources from service methods, deduplicating by type name.
691pub fn extract_managed_resources(
692    metadata: &CodeGenMetadata,
693    methods: &[MethodPlan],
694) -> Vec<ManagedResource> {
695    let mut resources = Vec::new();
696    let mut seen_types = HashSet::<String>::new();
697
698    for method in methods {
699        if let Some(ref resource_type) = method.output_resource_type {
700            if seen_types.contains(resource_type) {
701                continue;
702            }
703            if let Some(descriptor) = metadata.get_resource_descriptor(resource_type) {
704                resources.push(ManagedResource {
705                    type_name: resource_type.clone(),
706                    descriptor: descriptor.clone(),
707                });
708                seen_types.insert(resource_type.clone());
709            }
710        }
711    }
712
713    resources
714}
715
716#[cfg(test)]
717mod tests {
718    use super::*;
719
720    fn custom(p: Pattern) -> RequestType {
721        RequestType::Custom(p)
722    }
723
724    #[test]
725    fn request_has_body_matches_write_verbs() {
726        assert!(request_has_body(&RequestType::Create));
727        assert!(request_has_body(&RequestType::Update));
728        assert!(request_has_body(&custom(Pattern::Post(String::new()))));
729        assert!(request_has_body(&custom(Pattern::Patch(String::new()))));
730
731        assert!(!request_has_body(&RequestType::List));
732        assert!(!request_has_body(&RequestType::Get));
733        assert!(!request_has_body(&RequestType::Delete));
734        assert!(!request_has_body(&custom(Pattern::Get(String::new()))));
735        assert!(!request_has_body(&custom(Pattern::Delete(String::new()))));
736    }
737
738    #[test]
739    fn needs_request_parts_matches_read_verbs() {
740        assert!(request_needs_request_parts(&RequestType::List));
741        assert!(request_needs_request_parts(&RequestType::Get));
742        assert!(request_needs_request_parts(&RequestType::Delete));
743        assert!(request_needs_request_parts(&custom(Pattern::Get(
744            String::new()
745        ))));
746        assert!(request_needs_request_parts(&custom(Pattern::Delete(
747            String::new()
748        ))));
749
750        assert!(!request_needs_request_parts(&RequestType::Create));
751        assert!(!request_needs_request_parts(&RequestType::Update));
752        assert!(!request_needs_request_parts(&custom(Pattern::Post(
753            String::new()
754        ))));
755        assert!(!request_needs_request_parts(&custom(Pattern::Patch(
756            String::new()
757        ))));
758    }
759
760    #[test]
761    fn has_body_and_needs_parts_are_mutually_exclusive_for_known_verbs() {
762        // A method either reads from parts or carries a body — never both, never neither —
763        // for the verb shapes the emitters actually produce extractors/clients for.
764        for rt in [
765            RequestType::List,
766            RequestType::Create,
767            RequestType::Get,
768            RequestType::Update,
769            RequestType::Delete,
770            custom(Pattern::Get(String::new())),
771            custom(Pattern::Post(String::new())),
772            custom(Pattern::Patch(String::new())),
773            custom(Pattern::Delete(String::new())),
774        ] {
775            assert_ne!(
776                request_has_body(&rt),
777                request_needs_request_parts(&rt),
778                "body vs request-parts should partition {rt:?}"
779            );
780        }
781    }
782
783    #[test]
784    fn is_collection_method_standard_and_name_heuristics() {
785        // Standard collection verbs always qualify, regardless of name.
786        assert!(is_collection_method(&RequestType::List, "ListThings"));
787        assert!(is_collection_method(&RequestType::Create, "CreateThing"));
788
789        // Custom GET named `List*` (resource-less list) qualifies; other custom GETs don't.
790        assert!(is_collection_method(
791            &custom(Pattern::Get(String::new())),
792            "ListTagAssignments"
793        ));
794        assert!(!is_collection_method(
795            &custom(Pattern::Get(String::new())),
796            "GetTagAssignment"
797        ));
798
799        // Custom POST named `Generate*` (factory RPC) qualifies; other custom POSTs don't.
800        assert!(is_collection_method(
801            &custom(Pattern::Post(String::new())),
802            "GenerateCredentials"
803        ));
804        assert!(!is_collection_method(
805            &custom(Pattern::Post(String::new())),
806            "RotateToken"
807        ));
808
809        // Instance verbs never qualify.
810        assert!(!is_collection_method(&RequestType::Get, "GetThing"));
811        assert!(!is_collection_method(&RequestType::Update, "UpdateThing"));
812        assert!(!is_collection_method(&RequestType::Delete, "DeleteThing"));
813    }
814
815    #[test]
816    fn method_shape_classification() {
817        // Resource-less service → everything Unbound, regardless of verb.
818        assert_eq!(
819            method_shape(&RequestType::List, "ListTagAssignments", false),
820            MethodShape::Unbound
821        );
822        assert_eq!(
823            method_shape(&RequestType::Get, "GetTagAssignment", false),
824            MethodShape::Unbound
825        );
826        assert_eq!(
827            method_shape(&custom(Pattern::Post(String::new())), "TouchTag", false),
828            MethodShape::Unbound
829        );
830
831        // Resource service → collection verbs are CollectionScoped, instance verbs InstanceScoped.
832        assert_eq!(
833            method_shape(&RequestType::List, "ListCatalogs", true),
834            MethodShape::CollectionScoped
835        );
836        assert_eq!(
837            method_shape(&RequestType::Create, "CreateCatalog", true),
838            MethodShape::CollectionScoped
839        );
840        assert_eq!(
841            method_shape(
842                &custom(Pattern::Post(String::new())),
843                "GenerateCredentials",
844                true
845            ),
846            MethodShape::CollectionScoped
847        );
848        assert_eq!(
849            method_shape(&RequestType::Get, "GetCatalog", true),
850            MethodShape::InstanceScoped
851        );
852        assert_eq!(
853            method_shape(&RequestType::Update, "UpdateCatalog", true),
854            MethodShape::InstanceScoped
855        );
856        assert_eq!(
857            method_shape(&RequestType::Delete, "DeleteCatalog", true),
858            MethodShape::InstanceScoped
859        );
860        // Resource-targeted custom POST (not a factory) stays instance-scoped.
861        assert_eq!(
862            method_shape(&custom(Pattern::Post(String::new())), "RotateToken", true),
863            MethodShape::InstanceScoped
864        );
865    }
866
867    #[test]
868    fn emit_shape_classification() {
869        // Standard verbs map directly.
870        assert_eq!(emit_shape(&RequestType::List, false), EmitShape::List);
871        assert_eq!(emit_shape(&RequestType::Create, false), EmitShape::Create);
872        assert_eq!(emit_shape(&RequestType::Get, false), EmitShape::GetUpdate);
873        assert_eq!(
874            emit_shape(&RequestType::Update, false),
875            EmitShape::GetUpdate
876        );
877        assert_eq!(emit_shape(&RequestType::Delete, false), EmitShape::Delete);
878
879        // A collection-style custom POST/PATCH (a factory, e.g. `Generate*`) is Create-shaped.
880        assert_eq!(
881            emit_shape(&custom(Pattern::Post(String::new())), true),
882            EmitShape::Create
883        );
884        assert_eq!(
885            emit_shape(&custom(Pattern::Patch(String::new())), true),
886            EmitShape::Create
887        );
888
889        // A resource-targeted (non-collection) custom POST/PATCH is GetUpdate-shaped.
890        assert_eq!(
891            emit_shape(&custom(Pattern::Post(String::new())), false),
892            EmitShape::GetUpdate
893        );
894
895        // A custom GET (even named `List*`, is_collection=true) is NOT List-shaped — it returns the
896        // full response message, matching the old flat dispatch's `Custom(_) => get_update` arm.
897        assert_eq!(
898            emit_shape(&custom(Pattern::Get(String::new())), true),
899            EmitShape::GetUpdate
900        );
901        assert_eq!(
902            emit_shape(&custom(Pattern::Get(String::new())), false),
903            EmitShape::GetUpdate
904        );
905        // A custom DELETE is GetUpdate-shaped (the old flat `Custom(_)` arm), not Delete.
906        assert_eq!(
907            emit_shape(&custom(Pattern::Delete(String::new())), false),
908            EmitShape::GetUpdate
909        );
910    }
911
912    #[test]
913    fn scoped_verb_only_for_standard_instance_ops() {
914        assert_eq!(scoped_verb(&RequestType::Get).as_deref(), Some("get"));
915        assert_eq!(scoped_verb(&RequestType::Update).as_deref(), Some("update"));
916        assert_eq!(scoped_verb(&RequestType::Delete).as_deref(), Some("delete"));
917
918        assert_eq!(scoped_verb(&RequestType::List), None);
919        assert_eq!(scoped_verb(&RequestType::Create), None);
920        assert_eq!(scoped_verb(&custom(Pattern::Post(String::new()))), None);
921        assert_eq!(scoped_verb(&custom(Pattern::Get(String::new()))), None);
922    }
923}