Skip to main content

olai_codegen/analysis/
mod.rs

1//! Analysis module for processing protobuf metadata into code generation plans
2//!
3//! This module takes the raw metadata extracted from protobuf files and analyzes it
4//! to create a structured plan for code generation. It handles:
5//!
6//! - Grouping methods by service
7//! - Extracting HTTP routing information
8//! - Determining parameter types and sources
9//! - Planning the structure of generated code
10//! - Extracting managed resources from method return types
11//!
12//! ## Managed Resources
13//!
14//! Services often manage one or more resource types. These resources are automatically
15//! extracted from the return types of get, create, and update methods. For example:
16//!
17//! ```proto
18//! message Catalog {
19//!   option (google.api.resource) = {
20//!     type: "example.io/Catalog"
21//!     pattern: "catalogs/{catalog}"
22//!     plural: "catalogs"
23//!     singular: "catalog"
24//!   };
25//!   string name = 1;
26//!   // ... other fields
27//! }
28//!
29//! service CatalogService {
30//!   rpc GetCatalog(GetCatalogRequest) returns (Catalog);
31//!   rpc CreateCatalog(CreateCatalogRequest) returns (Catalog);
32//!   rpc UpdateCatalog(UpdateCatalogRequest) returns (Catalog);
33//! }
34//! ```
35//!
36//! The analysis will extract that `CatalogService` manages the `Catalog` resource,
37//! making this information available for subsequent code generation phases.
38
39use std::collections::{HashMap, HashSet};
40
41use tracing::warn;
42
43use crate::Result;
44use crate::google::api::FieldBehavior;
45use crate::parsing::types::BaseType;
46use crate::parsing::{CodeGenMetadata, HttpPattern, MessageField, MethodMetadata, ServiceInfo};
47use crate::utils::strings;
48
49pub(crate) use types::MethodPlanner;
50pub use types::{
51    BodyField, ChildLink, EmitShape, GenerationPlan, ManagedResource, MethodPlan, MethodShape,
52    PathParam, QueryParam, RequestParam, RequestType, ResourceHierarchy, ServicePlan,
53    SkippedMethod, extract_managed_resources, split_body_fields,
54};
55pub(crate) use types::{emit_shape, is_collection_method};
56
57mod types;
58
59/// Analyze collected metadata and create a generation plan.
60///
61/// Analysis is **protocol-agnostic**: every method gets a [`MethodPlan`]. REST analysis is a
62/// superset of what ConnectRPC needs, so a method with a `google.api.http` annotation carries the
63/// full REST shape (path/query/body split, URL template) and one without carries the routeless
64/// shape (all request fields as body). [`MethodPlan::has_http_route`] distinguishes them: the REST
65/// emitters generate only routed methods; the ConnectRPC emitter generates all. No method is
66/// dropped here, so [`GenerationPlan::skipped_methods`] is empty.
67///
68/// Hierarchy derivation uses a two-phase cross-service algorithm:
69/// 1. Build all service plans (hierarchy empty).
70/// 2. Construct a global parent map across all services, then assign depth-sorted hierarchies.
71pub fn analyze_metadata(metadata: &CodeGenMetadata) -> Result<GenerationPlan> {
72    // Phase 1: analyze all services without hierarchy.
73    //
74    // `metadata.services` is a `HashMap`, whose iteration order is non-deterministic. Sort by
75    // service name first so the resulting plan order (and thus emitted module declarations and the
76    // global parent map's insertion order) is reproducible across runs.
77    let mut service_infos: Vec<_> = metadata.services.values().collect();
78    service_infos.sort_by(|a, b| a.name.cmp(&b.name));
79
80    let plans = service_infos
81        .into_iter()
82        .map(|service_info| analyze_service(metadata, service_info))
83        .collect::<Result<Vec<_>>>()?;
84
85    // Phase 2: build the cross-service global parent map, then assign depth-sorted hierarchies
86    // and (once the hierarchy is known) the resource accessor param list.
87    let global_map = build_global_parent_map(&plans, metadata);
88    let mut services = plans
89        .into_iter()
90        .map(|mut plan| {
91            plan.hierarchy = derive_ordered_hierarchy(&plan, &global_map, metadata)?;
92            plan.resource_accessor_params = derive_resource_accessor_params(&plan);
93            Ok(plan)
94        })
95        .collect::<Result<Vec<_>>>()?;
96
97    // Phase 2b: derive direct children. This needs every service's accessor params (filled above),
98    // so it runs as a separate sub-pass over the now-complete set.
99    assign_direct_children(&mut services);
100
101    Ok(GenerationPlan {
102        services,
103        skipped_methods: Vec::new(),
104    })
105}
106
107// ── Cross-service hierarchy resolution ────────────────────────────────────────────────────────
108
109/// Maps `(parent_resource_type, child_resource_type)` → proto field name that carries the
110/// parent identifier on the child service's List request (e.g. `"catalog_name"`).
111type GlobalParentMap = HashMap<(String, String), String>;
112
113/// Build a map of immediate-parent relationships by scanning every service's List method query
114/// params for `child_type`-annotated fields.
115///
116/// **Key semantic filter:** only params where `child_type == this service's managed resource type`
117/// are recorded. This ensures that `catalog_name` on `ListTablesRequest` (child_type = Table) is
118/// recorded as `(Catalog, Table)`, and `schema_name` as `(Schema, Table)` — but when walking
119/// Schema's own List method, `catalog_name` (child_type = Schema) is recorded as `(Catalog, Schema)`.
120/// The chain `Catalog → Schema → Table` is then reconstructable via depth analysis.
121fn build_global_parent_map(plans: &[ServicePlan], metadata: &CodeGenMetadata) -> GlobalParentMap {
122    let mut map: GlobalParentMap = HashMap::new();
123
124    for plan in plans {
125        let managed_type = match plan.managed_resources.first() {
126            Some(r) => &r.descriptor.r#type,
127            None => continue,
128        };
129        if managed_type.is_empty() {
130            continue;
131        }
132
133        for method in &plan.methods {
134            if method.request_type != RequestType::List {
135                continue;
136            }
137            for param in method.query_parameters() {
138                let Some(ref rr) = param.resource_reference else {
139                    continue;
140                };
141                // Only record when child_type matches this service's own managed resource type.
142                if rr.child_type != *managed_type {
143                    continue;
144                }
145                if let Some(parent_type) = resolve_parent_type_from_field(&param.name, metadata) {
146                    map.entry((parent_type, managed_type.clone()))
147                        .or_insert_with(|| param.name.clone());
148                }
149            }
150        }
151    }
152
153    map
154}
155
156/// Derive the ordered ancestor chain for a service's managed resource using the global parent map.
157///
158/// Returns entries sorted **root-first** (shallowest ancestor first), so iterating them gives
159/// the correct left-to-right param order (e.g. `[catalog_name, schema_name]` for a Table service).
160///
161/// Returns `Err` if a cycle is detected in the resource hierarchy annotations.
162fn derive_ordered_hierarchy(
163    plan: &ServicePlan,
164    global_map: &GlobalParentMap,
165    metadata: &CodeGenMetadata,
166) -> Result<Vec<ResourceHierarchy>> {
167    let managed_type = match plan.managed_resources.first() {
168        Some(r) => r.descriptor.r#type.clone(),
169        None => return Ok(vec![]),
170    };
171
172    // Collect all (parent_resource_type, parent_field_name) entries for this managed resource.
173    let mut ancestors: Vec<(usize, String, String)> = global_map
174        .iter()
175        .filter(|((_, child), _)| child == &managed_type)
176        .map(|((parent_type, _), field_name)| {
177            let depth = compute_depth(parent_type, global_map, &mut HashSet::new())?;
178            Ok((depth, parent_type.clone(), field_name.clone()))
179        })
180        .collect::<Result<Vec<_>>>()?;
181
182    if ancestors.is_empty() {
183        return Ok(vec![]);
184    }
185
186    // Sort root-first (ascending depth).
187    ancestors.sort_by_key(|(depth, _, _)| *depth);
188
189    Ok(ancestors
190        .into_iter()
191        .map(|(_, parent_type, field_name)| {
192            let parent_singular = field_name.strip_suffix("_name").and_then(|stem| {
193                metadata
194                    .resource_from_singular(stem)
195                    .map(|_| stem.to_string())
196            });
197            ResourceHierarchy {
198                child_resource_type: managed_type.clone(),
199                parent_resource_type: parent_type,
200                parent_field_name: field_name,
201                parent_singular,
202            }
203        })
204        .collect())
205}
206
207/// Compute the depth of a resource type in the parent chain (0 = root, no parent in map).
208///
209/// Returns `Err` if a cycle is detected in the resource hierarchy annotations.
210fn compute_depth(
211    resource_type: &str,
212    map: &GlobalParentMap,
213    visited: &mut HashSet<String>,
214) -> Result<usize> {
215    if !visited.insert(resource_type.to_string()) {
216        return Err(crate::Error::Build(format!(
217            "Cycle detected in resource hierarchy at type: {resource_type}"
218        )));
219    }
220    let parent = map
221        .iter()
222        .find(|((_, child), _)| child == resource_type)
223        .map(|((parent, _), _)| parent.clone());
224
225    match parent {
226        None => Ok(0),
227        Some(parent_type) => Ok(1 + compute_depth(&parent_type, map, visited)?),
228    }
229}
230
231/// Resolve the resource type string for a proto field name by stripping `"_name"` and looking up
232/// the resulting singular in `metadata`.
233///
234/// Returns `None` if the field name doesn't end in `"_name"` or no resource matches.
235fn resolve_parent_type_from_field(field_name: &str, metadata: &CodeGenMetadata) -> Option<String> {
236    field_name
237        .strip_suffix("_name")
238        .and_then(|s| metadata.resource_from_singular(s))
239        .map(|rd| rd.r#type.clone())
240}
241
242/// Standard AIP-132 `List` request fields that are never part of a resource's name.
243///
244/// These are pagination/filtering knobs, not parent-hierarchy components. proto3 scalars carry no
245/// presence info, so a required `page_token` looks identical to a real parent name like
246/// `catalog_name` — the heuristic derivation must exclude these by name explicitly, or pagination
247/// fields leak into accessor params (e.g. a flat Catalog getting a bogus `(page_token, name)`).
248pub(crate) fn is_standard_list_field(name: &str) -> bool {
249    matches!(
250        name,
251        "page_token" | "page_size" | "max_results" | "order_by" | "filter"
252    )
253}
254
255/// Derive the ordered ancestor field names for a service's managed resource, *excluding* the
256/// resource's own leaf name. e.g. `["catalog_name"]` for Schema, `[]` for a flat Catalog.
257///
258/// **Annotation-driven path** (preferred): when the service has `hierarchy` entries from
259/// `resource_reference { child_type }` annotations, the parent field names come straight from those
260/// entries (in List-method param order).
261///
262/// **Heuristic fallback** (no annotations): the resource name is composite when either
263/// 1. the descriptor declares a `name_field` (an explicit dot-joined `full_name`), or
264/// 2. the Get method's path param is `"name"` (an opaque full path) *and* the List method has
265///    required string query params that aren't standard pagination fields — those are the parents.
266///
267/// Returns an empty vec for a flat (top-level) resource.
268pub(crate) fn derive_accessor_parent_fields(plan: &ServicePlan) -> Vec<String> {
269    let Some(resource) = plan.managed_resources.first() else {
270        return vec![];
271    };
272
273    // --- Annotation-driven path ---
274    let resource_type = &resource.descriptor.r#type;
275    if !plan.hierarchy.is_empty() && !resource_type.is_empty() {
276        let annotation_parents: Vec<String> = plan
277            .hierarchy
278            .iter()
279            .filter(|h| &h.child_resource_type == resource_type)
280            .map(|h| h.parent_field_name.clone())
281            .collect();
282        if !annotation_parents.is_empty() {
283            return annotation_parents;
284        }
285    }
286
287    // --- Heuristic fallback ---
288    let has_explicit_name_field = !resource.descriptor.name_field.is_empty();
289
290    let get_path_param_name = plan
291        .methods
292        .iter()
293        .find(|m| m.request_type == RequestType::Get)
294        .and_then(|m| m.path_parameters().next().map(|p| p.name.clone()));
295
296    let parent_params: Vec<String> = plan
297        .methods
298        .iter()
299        .find(|m| m.request_type == RequestType::List)
300        .map(|m| {
301            m.parameters
302                .iter()
303                .filter(|p| !p.is_path_param() && !p.is_optional())
304                .filter(|p| matches!(p.field_type().base_type, BaseType::String))
305                .filter(|p| !is_standard_list_field(p.name()))
306                .map(|p| p.name().to_string())
307                .collect()
308        })
309        .unwrap_or_default();
310
311    let should_decompose = has_explicit_name_field
312        || (get_path_param_name.as_deref() == Some("name") && !parent_params.is_empty());
313
314    if should_decompose {
315        parent_params
316    } else {
317        vec![]
318    }
319}
320
321/// Derive the full resource accessor param list (ancestor fields + the resource's own
322/// `<singular>_name` leaf), or `None` for resource-less services.
323///
324/// Single source of truth consumed by all four accessor emitters (Rust aggregate + Python + Node +
325/// TypeScript). A returned list of length 1 (`["<singular>_name"]`) is a flat resource; length > 1
326/// is nested (its full name is the dot-joined components).
327fn derive_resource_accessor_params(plan: &ServicePlan) -> Option<Vec<String>> {
328    let resource = plan.managed_resources.first()?;
329    let mut params = derive_accessor_parent_fields(plan);
330    params.push(format!("{}_name", resource.descriptor.singular));
331    Some(params)
332}
333
334/// Populate each service's [`ServicePlan::direct_children`] using the accessor-param prefix relation.
335///
336/// A resource-scoped service C is a *direct child* of resource-scoped service P when C's accessor
337/// params extend P's by exactly one trailing component (P's params are a prefix of C's and
338/// `C.len() == P.len() + 1`). Children are sorted by singular for deterministic output.
339fn assign_direct_children(services: &mut [ServicePlan]) {
340    // Snapshot the (accessor params, base_path, singular) of every resource-scoped service so we can
341    // scan for children while mutating each parent's `direct_children` in place.
342    let candidates: Vec<(Vec<String>, String, String)> = services
343        .iter()
344        .filter_map(|s| {
345            let params = s.resource_accessor_params.clone()?;
346            let singular = s.managed_resources.first()?.descriptor.singular.clone();
347            Some((params, s.base_path.clone(), singular))
348        })
349        .collect();
350
351    for plan in services.iter_mut() {
352        let Some(parent_params) = plan.resource_accessor_params.clone() else {
353            continue;
354        };
355        let mut children: Vec<ChildLink> = candidates
356            .iter()
357            .filter(|(child_params, child_base_path, _)| {
358                *child_base_path != plan.base_path
359                    && child_params.len() == parent_params.len() + 1
360                    && child_params[..parent_params.len()] == parent_params[..]
361            })
362            .map(
363                |(child_params, child_base_path, child_singular)| ChildLink {
364                    child_singular: child_singular.clone(),
365                    child_base_path: child_base_path.clone(),
366                    child_accessor_params: child_params.clone(),
367                },
368            )
369            .collect();
370        children.sort_by(|a, b| a.child_singular.cmp(&b.child_singular));
371        plan.direct_children = children;
372    }
373}
374
375/// Analyze a single service and create a service plan.
376fn analyze_service(metadata: &CodeGenMetadata, info: &ServiceInfo) -> Result<ServicePlan> {
377    let handler_name = strings::service_to_handler_name(&info.name);
378    let base_path = strings::service_to_base_path(&info.name);
379
380    // Analysis is protocol-agnostic: every method gets a plan. Methods without an HTTP annotation
381    // get the routeless shape (`has_http_route == false`); the REST emitters skip them while the
382    // ConnectRPC emitter uses them. So no method is dropped at analysis time.
383    let mut method_plans = info
384        .methods
385        .iter()
386        .map(|method| analyze_method(metadata, method))
387        .collect::<Result<Vec<_>>>()?;
388
389    let managed_resources = types::extract_managed_resources(metadata, &method_plans);
390
391    // Second pass: now that we know whether this service manages a resource, classify each method's
392    // shape (collection / instance / unbound). `analyze_method` couldn't do this — it has no view of
393    // the service's resources.
394    let service_has_resource = !managed_resources.is_empty();
395    for plan in &mut method_plans {
396        plan.shape = types::method_shape(
397            &plan.request_type,
398            &plan.metadata.method_name,
399            service_has_resource,
400        );
401    }
402
403    Ok(ServicePlan {
404        service_name: info.name.clone(),
405        handler_name,
406        base_path,
407        package: info.package.clone(),
408        methods: method_plans,
409        managed_resources,
410        documentation: info.documentation.clone(),
411        hierarchy: vec![],              // filled in Phase 2 of analyze_metadata
412        resource_accessor_params: None, // filled in Phase 2 of analyze_metadata
413        direct_children: vec![],        // filled in Phase 2b of analyze_metadata
414    })
415}
416
417/// Analyze a single method and create a method plan.
418///
419/// Analysis is **protocol-agnostic** and produces the superset of information both the REST and the
420/// ConnectRPC emitters consume. Every method gets a plan:
421///
422/// - With a `google.api.http` annotation: the full REST shape (path/query/body split, URL template,
423///   request type). [`MethodPlan::has_http_route`] is `true`. The Connect emitter ignores the
424///   routing fields and reads only the body fields + input/output types.
425/// - Without one: the routeless shape (all request fields as body, no path/query — see
426///   [`analyze_routeless_method`]). `has_http_route` is `false`; the REST emitters skip it (it has
427///   no route to call), the Connect emitter emits it.
428pub(crate) fn analyze_method(
429    metadata: &CodeGenMetadata,
430    method: &MethodMetadata,
431) -> Result<MethodPlan> {
432    let Some(http_method) = method.http_method().map(|m| m.to_string()) else {
433        // No HTTP annotation: still produce a plan (the routeless / Connect-degenerate shape) so the
434        // ConnectRPC emitter can use it. REST emitters gate on `has_http_route`.
435        return analyze_routeless_method(metadata, method);
436    };
437
438    let planner = MethodPlanner::try_new(method, metadata)?;
439    let request_type = planner.request_type();
440    let has_response = planner.has_response();
441    let output_resource_type = planner.output_resource_type();
442    let http_pattern = planner.into_http_pattern();
443
444    let input_fields = metadata.get_message_fields(&method.input_type);
445    let (path_params, query_params, body_fields) = extract_request_fields(method, &input_fields)?;
446
447    let parameters = path_params
448        .into_iter()
449        .map(Into::into)
450        .chain(query_params.into_iter().map(Into::into))
451        .chain(body_fields.into_iter().map(Into::into))
452        .collect();
453
454    // A method whose HTTP rule names a body has one, whatever the verb.
455    let has_request_body =
456        types::request_has_body(&request_type) || !method.http_rule.body.is_empty();
457    let needs_request_parts = types::request_needs_request_parts(&request_type);
458    let scoped_verb = types::scoped_verb(&request_type);
459    let (raw_request_body, raw_response_body) = raw_bodies(metadata, method, &input_fields)?;
460    let request_headers = request_headers(method, &input_fields)?;
461
462    Ok(MethodPlan {
463        metadata: method.clone(),
464        handler_function_name: strings::operation_to_method_name(&method.method_name),
465        http_method,
466        parameters,
467        has_response,
468        request_type,
469        output_resource_type,
470        http_pattern,
471        has_request_body,
472        needs_request_parts,
473        scoped_verb,
474        // Provisional: `shape` depends on the owning service's managed resources, which aren't
475        // known here. `analyze_service` overwrites this once `extract_managed_resources` has run.
476        shape: types::MethodShape::Unbound,
477        has_http_route: true,
478        raw_request_body,
479        raw_response_body,
480        request_headers,
481    })
482}
483
484/// The `(olai.trestle.v1.request_header)` fields of the request with their headers: `string`
485/// fields outside the body.
486fn request_headers(
487    method: &MethodMetadata,
488    input_fields: &[MessageField],
489) -> Result<Vec<(String, String)>> {
490    input_fields
491        .iter()
492        .filter_map(|f| Some((f, f.request_header.clone()?)))
493        .map(|(f, header)| {
494            let fail = |message: &str| {
495                Err(crate::Error::Build(format!(
496                    "method `{}`: the request header field `{}` {message}",
497                    method.method_name, f.name
498                )))
499            };
500            if !matches!(f.unified_type.base_type, BaseType::String) || f.unified_type.is_repeated {
501                return fail("must be a `string`");
502            }
503            if matches!(method.http_rule.body.as_str(), "*") || method.http_rule.body == f.name {
504                return fail("cannot be in the body");
505            }
506            Ok((f.name.clone(), header))
507        })
508        .collect()
509}
510
511/// The `(olai.trestle.v1.raw_body)` fields of the request and the response: singular `bytes`
512/// fields. The request one must be the rule's `body`; the response one needs a non-streaming
513/// method whose other output fields come from response headers.
514fn raw_bodies(
515    metadata: &CodeGenMetadata,
516    method: &MethodMetadata,
517    input_fields: &[MessageField],
518) -> Result<(Option<String>, Option<String>)> {
519    let fail = |message: String| {
520        Err(crate::Error::Build(format!(
521            "method `{}`: {message}",
522            method.method_name
523        )))
524    };
525    let request = match input_fields.iter().find(|f| f.raw_body) {
526        None => None,
527        Some(f) if !f.unified_type.is_singular_bytes() => {
528            return fail(format!("the raw body `{}` must be `bytes`", f.name));
529        }
530        Some(f) if method.http_rule.body != f.name => {
531            return fail(format!(
532                "the rule's `body` must name the raw body `{}`",
533                f.name
534            ));
535        }
536        Some(f) => Some(f.name.clone()),
537    };
538    let output_fields = metadata.get_message_fields(&method.output_type);
539    let response = match output_fields.iter().find(|f| f.raw_body) {
540        None => None,
541        Some(f) if !f.unified_type.is_singular_bytes() => {
542            return fail(format!("the raw body `{}` must be `bytes`", f.name));
543        }
544        Some(_) if method.server_streaming => {
545            return fail("a streamed output has no raw body".to_string());
546        }
547        Some(f)
548            if output_fields
549                .iter()
550                .any(|o| o.name != f.name && o.response_header.is_none()) =>
551        {
552            return fail(format!(
553                "the other fields of an output with the raw body `{}` must come from response headers",
554                f.name
555            ));
556        }
557        Some(f) => Some(f.name.clone()),
558    };
559    Ok((request, response))
560}
561
562/// Analyze a method that has no `google.api.http` annotation (the ConnectRPC-degenerate shape).
563///
564/// ConnectRPC dispatch sends the whole request message as the body and reads the whole response, so
565/// there is no path/query/body split and no URL template. Every non-`OUTPUT_ONLY` request field
566/// becomes a [`BodyField`], partitioned required-vs-optional purely from
567/// `google.api.field_behavior` — the same `BodyField` shape the REST builders consume, so the
568/// builder layer ([`crate::codegen::builder`]) is reused unchanged.
569///
570/// The HTTP-only fields of [`MethodPlan`] (`http_method`, `http_pattern`) are left empty/default and
571/// [`MethodPlan::has_http_route`] is `false`; the REST emitters skip such methods, so those fields
572/// are never read for them.
573fn analyze_routeless_method(
574    metadata: &CodeGenMetadata,
575    method: &MethodMetadata,
576) -> Result<MethodPlan> {
577    let input_fields = metadata.get_message_fields(&method.input_type);
578    let body_fields = routeless_body_fields(&input_fields);
579
580    // A routeless method carries no REST request shape. `Custom` keeps it off the REST collection /
581    // instance code paths (resource-scoped clients, pagination heuristics) while still flowing
582    // through the shared builder generator. The verb is irrelevant for Connect dispatch; `POST`
583    // is the conventional placeholder.
584    let request_type =
585        RequestType::Custom(crate::google::api::http_rule::Pattern::Post(String::new()));
586    let has_response = !method.output_type.is_empty() && !method.output_type.ends_with("Empty");
587
588    Ok(MethodPlan {
589        metadata: method.clone(),
590        handler_function_name: strings::operation_to_method_name(&method.method_name),
591        http_method: String::new(),
592        parameters: body_fields.into_iter().map(Into::into).collect(),
593        has_response,
594        request_type,
595        output_resource_type: None,
596        http_pattern: HttpPattern::default(),
597        // The request message is always carried as the body for Connect dispatch.
598        has_request_body: true,
599        needs_request_parts: false,
600        scoped_verb: None,
601        shape: types::MethodShape::Unbound,
602        has_http_route: false,
603        raw_request_body: None,
604        raw_response_body: None,
605        request_headers: Vec::new(),
606    })
607}
608
609/// Classify a routeless request message's fields into [`BodyField`]s.
610///
611/// `OUTPUT_ONLY` fields are dropped (server-generated). Oneof fields are optional body fields with
612/// their variants preserved. Every other field is a body field whose `required` flag is taken from
613/// `google.api.field_behavior` REQUIRED — mirroring the `body: "*"` branch of
614/// [`extract_request_fields`], minus the path/query split that ConnectRPC has no notion of.
615fn routeless_body_fields(input_fields: &[MessageField]) -> Vec<BodyField> {
616    input_fields
617        .iter()
618        .filter(|field| !field.field_behavior.contains(&FieldBehavior::OutputOnly))
619        .map(body_field)
620        .collect()
621}
622
623/// The body field for a request field.
624///
625/// A oneof is always optional and keeps its variants. Otherwise `required` comes from
626/// `google.api.field_behavior` REQUIRED, and a non-required singular message is marked optional on
627/// the type so it becomes a `with_*(impl Into<Option<T>>)` setter (and an `Option<T>` with a
628/// `= None` default in FFI bindings) rather than a required argument. Scalars and collections are
629/// unaffected (their optionality is already correct).
630fn body_field(field: &MessageField) -> BodyField {
631    if matches!(field.unified_type.base_type, BaseType::OneOf(_)) {
632        return BodyField {
633            name: field.name.clone(),
634            field_type: field.unified_type.clone().optional(),
635            repeated: false,
636            required: false,
637            oneof_variants: field.oneof_variants.clone(),
638            documentation: field.documentation.clone(),
639        };
640    }
641
642    let required = field.field_behavior.contains(&FieldBehavior::Required);
643    let needs_optional_marker = !required
644        && !field.unified_type.is_repeated
645        && matches!(field.unified_type.base_type, BaseType::Message(_));
646    let field_type = if needs_optional_marker {
647        field.unified_type.clone().optional()
648    } else {
649        field.unified_type.clone()
650    };
651    BodyField {
652        name: field.name.clone(),
653        field_type,
654        repeated: field.unified_type.is_repeated,
655        required,
656        oneof_variants: None,
657        documentation: field.documentation.clone(),
658    }
659}
660
661/// Extract and classify request fields from an input message into path, query, and body buckets.
662///
663/// - Path parameters are matched against URL template parameters and ordered accordingly.
664/// - Fields annotated `OUTPUT_ONLY` are excluded entirely — they are server-generated and
665///   must not appear in request extractors or client request builders.
666/// - Fields matching the `body` spec (`"*"`, `""`, or a specific field name) become body fields.
667/// - All remaining fields become query parameters. Fields not explicitly marked optional
668///   via `UnifiedType.is_optional` are treated as required query parameters.
669/// - Oneof fields are always placed in the body as optional variants.
670fn extract_request_fields(
671    method: &MethodMetadata,
672    input_fields: &[MessageField],
673) -> Result<(Vec<PathParam>, Vec<QueryParam>, Vec<BodyField>)> {
674    let mut path_params = Vec::new();
675    let mut query_params = Vec::new();
676    let mut body_fields = Vec::new();
677
678    let path_param_names = method.http_pattern.parameter_names();
679    let body_spec = method.http_rule.body.as_str();
680
681    // Build an O(1) lookup map for input fields by name.
682    let fields_by_name: HashMap<&str, &MessageField> =
683        input_fields.iter().map(|f| (f.name.as_str(), f)).collect();
684
685    let mut processed_fields = HashSet::new();
686
687    // Add path parameters in URL template order. `parameter_names()` yields normalized flat field
688    // names from the URL template (segment-binding `=pattern` suffixes already stripped by the
689    // template parser; unsupported dotted placeholders are kept out of `parameters` entirely).
690    for path_param_name in path_param_names {
691        if let Some(field) = fields_by_name.get(path_param_name.as_str()) {
692            // Path params are always included even if OUTPUT_ONLY (they appear in the URL, not
693            // the request body), but in practice OUTPUT_ONLY fields should never be path params.
694            path_params.push(PathParam {
695                name: field.name.clone(),
696                field_type: field.unified_type.clone(),
697                documentation: field.documentation.clone(),
698            });
699            processed_fields.insert(field.name.as_str());
700        } else {
701            // Unresolved placeholder: warn rather than silently misrouting the field to query/body.
702            warn!(
703                "Method {}.{}: URL path placeholder `{}` does not match any request field; \
704                 the param will be omitted from the path",
705                method.service_name, method.method_name, path_param_name
706            );
707        }
708    }
709
710    // Classify remaining fields as body or query.
711    for field in input_fields {
712        let field_name = field.name.as_str();
713
714        if processed_fields.contains(field_name) {
715            continue;
716        }
717
718        // Skip OUTPUT_ONLY fields — they are server-generated and never provided by clients.
719        if field.field_behavior.contains(&FieldBehavior::OutputOnly) {
720            processed_fields.insert(field_name);
721            continue;
722        }
723
724        // Oneof fields are always body fields and always optional.
725        if matches!(field.unified_type.base_type, BaseType::OneOf(_)) {
726            body_fields.push(body_field(field));
727            processed_fields.insert(field_name);
728            continue;
729        }
730
731        let is_body = match body_spec {
732            "*" => true,
733            "" => false,
734            specific => specific == field_name,
735        };
736
737        if is_body {
738            body_fields.push(body_field(field));
739        } else {
740            query_params.push(QueryParam {
741                name: field.name.clone(),
742                json_name: field.json_name.clone(),
743                field_type: field.unified_type.clone(),
744                documentation: field.documentation.clone(),
745                resource_reference: field.resource_reference.clone(),
746            });
747        }
748        processed_fields.insert(field_name);
749    }
750
751    Ok((path_params, query_params, body_fields))
752}
753
754#[cfg(test)]
755mod tests {
756    use super::*;
757    use crate::google::api::{HttpRule, ResourceDescriptor, http_rule::Pattern};
758    use crate::parsing::types::UnifiedType;
759    use crate::parsing::{CodeGenMetadata, HttpPattern, MessageInfo, MethodMetadata, ServiceInfo};
760    use std::collections::HashMap;
761
762    fn make_metadata_with_catalog() -> CodeGenMetadata {
763        let catalog_resource = ResourceDescriptor {
764            r#type: "example.io/Catalog".to_string(),
765            pattern: vec!["catalogs/{catalog}".to_string()],
766            name_field: "name".to_string(),
767            history: 0,
768            plural: "catalogs".to_string(),
769            singular: "catalog".to_string(),
770            style: vec![],
771        };
772        let catalog_info = MessageInfo {
773            name: "Catalog".to_string(),
774            fields: vec![],
775            resource_descriptor: Some(catalog_resource),
776            documentation: None,
777        };
778        let mut messages = HashMap::new();
779        messages.insert("Catalog".to_string(), catalog_info);
780        CodeGenMetadata {
781            messages,
782            ..Default::default()
783        }
784    }
785
786    fn make_get_method() -> MethodMetadata {
787        MethodMetadata {
788            service_name: "CatalogService".to_string(),
789            method_name: "GetCatalog".to_string(),
790            input_type: "GetCatalogRequest".to_string(),
791            output_type: "Catalog".to_string(),
792            server_streaming: false,
793            operation: None,
794            http_rule: HttpRule {
795                selector: "".to_string(),
796                pattern: Some(Pattern::Get("/catalogs/{name}".to_string())),
797                body: "".to_string(),
798                response_body: "".to_string(),
799                additional_bindings: vec![],
800            },
801            http_pattern: HttpPattern::parse("/catalogs/{name}"),
802            documentation: None,
803        }
804    }
805
806    #[test]
807    fn test_managed_resources_extraction() {
808        let metadata = make_metadata_with_catalog();
809        let service_info = ServiceInfo {
810            name: "CatalogService".to_string(),
811            package: "example.catalogs.v1".to_string(),
812            documentation: None,
813            methods: vec![make_get_method()],
814        };
815        let service_plan = analyze_service(&metadata, &service_info).unwrap();
816
817        assert_eq!(service_plan.managed_resources.len(), 1);
818        assert_eq!(service_plan.managed_resources[0].type_name, "Catalog");
819        assert_eq!(
820            service_plan.managed_resources[0].descriptor.r#type,
821            "example.io/Catalog"
822        );
823        assert_eq!(
824            service_plan.managed_resources[0].descriptor.singular,
825            "catalog"
826        );
827        assert_eq!(
828            service_plan.managed_resources[0].descriptor.plural,
829            "catalogs"
830        );
831    }
832
833    #[test]
834    fn test_no_duplicate_managed_resources() {
835        let metadata = make_metadata_with_catalog();
836        let update_method = MethodMetadata {
837            service_name: "CatalogService".to_string(),
838            method_name: "UpdateCatalog".to_string(),
839            input_type: "UpdateCatalogRequest".to_string(),
840            output_type: "Catalog".to_string(),
841            server_streaming: false,
842            operation: None,
843            http_rule: HttpRule {
844                selector: "".to_string(),
845                pattern: Some(Pattern::Patch("/catalogs/{name}".to_string())),
846                body: "*".to_string(),
847                response_body: "".to_string(),
848                additional_bindings: vec![],
849            },
850            http_pattern: HttpPattern::parse("/catalogs/{name}"),
851            documentation: None,
852        };
853        let service_info = ServiceInfo {
854            name: "CatalogService".to_string(),
855            package: "example.catalogs.v1".to_string(),
856            documentation: None,
857            methods: vec![make_get_method(), update_method],
858        };
859        let service_plan = analyze_service(&metadata, &service_info).unwrap();
860
861        assert_eq!(service_plan.managed_resources.len(), 1);
862        assert_eq!(service_plan.managed_resources[0].type_name, "Catalog");
863    }
864
865    #[test]
866    fn test_analyze_method_missing_http_pattern_is_routeless() {
867        let metadata = CodeGenMetadata::default();
868        let method = MethodMetadata {
869            service_name: "SomeService".to_string(),
870            method_name: "SomeMethod".to_string(),
871            input_type: "".to_string(),
872            output_type: "".to_string(),
873            server_streaming: false,
874            operation: None,
875            http_rule: HttpRule {
876                selector: "".to_string(),
877                pattern: None,
878                body: "".to_string(),
879                response_body: "".to_string(),
880                additional_bindings: vec![],
881            },
882            http_pattern: HttpPattern::parse(""),
883            documentation: None,
884        };
885        // Protocol-agnostic analysis still produces a plan, but flags it as having no HTTP route so
886        // the REST emitters skip it while the Connect emitter uses it.
887        let plan = analyze_method(&metadata, &method).unwrap();
888        assert!(!plan.has_http_route);
889        assert!(plan.http_method.is_empty());
890    }
891
892    #[test]
893    fn test_routeless_method_partitions_body_by_field_behavior() {
894        use crate::parsing::MessageInfo;
895
896        // A request with one REQUIRED field and one unmarked (optional) field, and NO HTTP rule.
897        let req = MessageInfo {
898            name: "DoThingRequest".to_string(),
899            fields: vec![
900                {
901                    let mut f = make_string_field("name", false);
902                    f.field_behavior = vec![FieldBehavior::Required];
903                    f
904                },
905                make_string_field("comment", false),
906            ],
907            resource_descriptor: None,
908            documentation: None,
909        };
910        let mut messages = HashMap::new();
911        messages.insert("DoThingRequest".to_string(), req);
912        let metadata = CodeGenMetadata {
913            messages,
914            ..Default::default()
915        };
916
917        let method = MethodMetadata {
918            service_name: "ThingService".to_string(),
919            method_name: "DoThing".to_string(),
920            input_type: "DoThingRequest".to_string(),
921            output_type: "DoThingResponse".to_string(),
922            server_streaming: false,
923            operation: None,
924            http_rule: HttpRule {
925                selector: "".to_string(),
926                pattern: None, // no HTTP annotation
927                body: "".to_string(),
928                response_body: "".to_string(),
929                additional_bindings: vec![],
930            },
931            http_pattern: HttpPattern::default(),
932            documentation: None,
933        };
934
935        let plan = analyze_method(&metadata, &method).unwrap();
936        assert!(!plan.has_http_route);
937
938        // No path/query params: every field is a body field.
939        assert_eq!(plan.path_parameters().count(), 0);
940        assert_eq!(plan.query_parameters().count(), 0);
941        let body: Vec<_> = plan.body_fields().collect();
942        assert_eq!(body.len(), 2);
943
944        // Required vs optional follows field_behavior: `name` (REQUIRED) is required, `comment` not.
945        let name = body.iter().find(|b| b.name == "name").unwrap();
946        let comment = body.iter().find(|b| b.name == "comment").unwrap();
947        assert!(name.required, "REQUIRED field should be a constructor arg");
948        assert!(
949            !comment.required,
950            "unmarked field should be an optional with_* setter"
951        );
952        assert_eq!(plan.handler_function_name, "do_thing");
953        assert!(plan.has_response);
954    }
955
956    #[test]
957    fn test_routeless_method_drops_output_only_fields() {
958        use crate::parsing::MessageInfo;
959
960        let req = MessageInfo {
961            name: "MakeReq".to_string(),
962            fields: vec![make_string_field("input", false), {
963                let mut f = make_string_field("server_set", false);
964                f.field_behavior = vec![FieldBehavior::OutputOnly];
965                f
966            }],
967            resource_descriptor: None,
968            documentation: None,
969        };
970        let mut messages = HashMap::new();
971        messages.insert("MakeReq".to_string(), req);
972        let metadata = CodeGenMetadata {
973            messages,
974            ..Default::default()
975        };
976
977        let method = MethodMetadata {
978            service_name: "S".to_string(),
979            method_name: "Make".to_string(),
980            input_type: "MakeReq".to_string(),
981            output_type: "MakeResp".to_string(),
982            server_streaming: false,
983            operation: None,
984            http_rule: HttpRule::default(),
985            http_pattern: HttpPattern::default(),
986            documentation: None,
987        };
988
989        let plan = analyze_method(&metadata, &method).unwrap();
990        let body: Vec<_> = plan.body_fields().collect();
991        assert_eq!(body.len(), 1, "OUTPUT_ONLY field must be dropped");
992        assert_eq!(body[0].name, "input");
993    }
994
995    // --- extract_request_fields unit tests ---
996
997    fn make_string_field(name: &str, optional: bool) -> MessageField {
998        use crate::parsing::types::BaseType;
999        MessageField {
1000            name: name.to_string(),
1001            json_name: None,
1002            response_header: None,
1003            request_header: None,
1004            emit_unpopulated: false,
1005            raw_body: false,
1006            unified_type: UnifiedType {
1007                base_type: BaseType::String,
1008                is_optional: optional,
1009                is_repeated: false,
1010            },
1011            documentation: None,
1012            oneof_variants: None,
1013            field_behavior: vec![],
1014            is_sensitive: false,
1015            resource_reference: None,
1016        }
1017    }
1018
1019    fn make_repeated_field(name: &str) -> MessageField {
1020        use crate::parsing::types::BaseType;
1021        MessageField {
1022            name: name.to_string(),
1023            json_name: None,
1024            response_header: None,
1025            request_header: None,
1026            emit_unpopulated: false,
1027            raw_body: false,
1028            unified_type: UnifiedType {
1029                base_type: BaseType::String,
1030                is_optional: false,
1031                is_repeated: true,
1032            },
1033            documentation: None,
1034            oneof_variants: None,
1035            field_behavior: vec![],
1036            is_sensitive: false,
1037            resource_reference: None,
1038        }
1039    }
1040
1041    fn make_method_with_pattern(pattern: Pattern, body: &str, path: &str) -> MethodMetadata {
1042        MethodMetadata {
1043            service_name: "Svc".to_string(),
1044            method_name: "Method".to_string(),
1045            input_type: "".to_string(),
1046            output_type: "".to_string(),
1047            server_streaming: false,
1048            operation: None,
1049            http_rule: HttpRule {
1050                selector: "".to_string(),
1051                pattern: Some(pattern),
1052                body: body.to_string(),
1053                response_body: "".to_string(),
1054                additional_bindings: vec![],
1055            },
1056            http_pattern: HttpPattern::parse(path),
1057            documentation: None,
1058        }
1059    }
1060
1061    #[test]
1062    fn test_extract_path_params_in_url_order() {
1063        let method =
1064            make_method_with_pattern(Pattern::Get("/a/{x}/b/{y}".to_string()), "", "/a/{x}/b/{y}");
1065        let fields = vec![make_string_field("y", false), make_string_field("x", false)];
1066        let (path, query, body) = extract_request_fields(&method, &fields).unwrap();
1067        // Path params should be in URL order: x, y — not field declaration order
1068        assert_eq!(path.len(), 2);
1069        assert_eq!(path[0].name, "x");
1070        assert_eq!(path[1].name, "y");
1071        assert!(query.is_empty());
1072        assert!(body.is_empty());
1073    }
1074
1075    #[test]
1076    fn test_extract_body_wildcard() {
1077        let method = make_method_with_pattern(Pattern::Post("/items".to_string()), "*", "/items");
1078        let fields = vec![
1079            make_string_field("name", false),
1080            make_string_field("description", true),
1081        ];
1082        let (path, query, body) = extract_request_fields(&method, &fields).unwrap();
1083        assert!(path.is_empty());
1084        assert!(query.is_empty());
1085        assert_eq!(body.len(), 2);
1086    }
1087
1088    #[test]
1089    fn test_extract_specific_body_field() {
1090        let method = make_method_with_pattern(
1091            Pattern::Patch("/items/{name}".to_string()),
1092            "payload",
1093            "/items/{name}",
1094        );
1095        let fields = vec![
1096            make_string_field("name", false),    // path
1097            make_string_field("payload", false), // body (specific)
1098            make_string_field("extra", true),    // query
1099        ];
1100        let (path, query, body) = extract_request_fields(&method, &fields).unwrap();
1101        assert_eq!(path.len(), 1);
1102        assert_eq!(path[0].name, "name");
1103        assert_eq!(body.len(), 1);
1104        assert_eq!(body[0].name, "payload");
1105        assert_eq!(query.len(), 1);
1106        assert_eq!(query[0].name, "extra");
1107    }
1108
1109    #[test]
1110    fn test_extract_no_body_spec_all_query() {
1111        let method = make_method_with_pattern(Pattern::Get("/items".to_string()), "", "/items");
1112        let fields = vec![
1113            make_string_field("filter", true),
1114            make_string_field("page_size", true),
1115        ];
1116        let (path, query, body) = extract_request_fields(&method, &fields).unwrap();
1117        assert!(path.is_empty());
1118        assert_eq!(query.len(), 2);
1119        assert!(body.is_empty());
1120    }
1121
1122    #[test]
1123    fn test_extract_repeated_field_becomes_body_with_repeated_flag() {
1124        let method = make_method_with_pattern(Pattern::Post("/items".to_string()), "*", "/items");
1125        let fields = vec![make_repeated_field("tags")];
1126        let (_, _, body) = extract_request_fields(&method, &fields).unwrap();
1127        assert_eq!(body.len(), 1);
1128        assert!(body[0].repeated);
1129    }
1130
1131    // ── Cross-service hierarchy chain resolution tests ─────────────────────────────────────────
1132
1133    /// Build a three-level metadata fixture: Catalog → Schema → Table.
1134    ///
1135    /// Three services:
1136    /// - CatalogService: ListCatalogs (no child_type params), GetCatalog
1137    /// - SchemaService: ListSchemas (catalog_name with child_type = Schema), GetSchema
1138    /// - TableService: ListTables (catalog_name and schema_name both with child_type = Table), GetTable
1139    fn make_three_level_metadata() -> (CodeGenMetadata, ServiceInfo, ServiceInfo, ServiceInfo) {
1140        use crate::google::api::ResourceReference;
1141        use crate::parsing::types::BaseType;
1142
1143        let mut messages = HashMap::new();
1144
1145        // Catalog resource
1146        messages.insert(
1147            "Catalog".to_string(),
1148            MessageInfo {
1149                name: "Catalog".to_string(),
1150                fields: vec![],
1151                resource_descriptor: Some(ResourceDescriptor {
1152                    r#type: "example.io/Catalog".to_string(),
1153                    pattern: vec!["catalogs/{catalog}".to_string()],
1154                    name_field: "name".to_string(),
1155                    history: 0,
1156                    plural: "catalogs".to_string(),
1157                    singular: "catalog".to_string(),
1158                    style: vec![],
1159                }),
1160                documentation: None,
1161            },
1162        );
1163
1164        // Schema resource
1165        messages.insert(
1166            "Schema".to_string(),
1167            MessageInfo {
1168                name: "Schema".to_string(),
1169                fields: vec![],
1170                resource_descriptor: Some(ResourceDescriptor {
1171                    r#type: "example.io/Schema".to_string(),
1172                    pattern: vec!["schemas/{schema}".to_string()],
1173                    name_field: "name".to_string(),
1174                    history: 0,
1175                    plural: "schemas".to_string(),
1176                    singular: "schema".to_string(),
1177                    style: vec![],
1178                }),
1179                documentation: None,
1180            },
1181        );
1182
1183        // Table resource
1184        messages.insert(
1185            "Table".to_string(),
1186            MessageInfo {
1187                name: "Table".to_string(),
1188                fields: vec![],
1189                resource_descriptor: Some(ResourceDescriptor {
1190                    r#type: "example.io/Table".to_string(),
1191                    pattern: vec!["tables/{table}".to_string()],
1192                    name_field: "full_name".to_string(),
1193                    history: 0,
1194                    plural: "tables".to_string(),
1195                    singular: "table".to_string(),
1196                    style: vec![],
1197                }),
1198                documentation: None,
1199            },
1200        );
1201
1202        // ListCatalogsRequest — no child_type params
1203        messages.insert(
1204            "ListCatalogsRequest".to_string(),
1205            MessageInfo {
1206                name: "ListCatalogsRequest".to_string(),
1207                fields: vec![],
1208                resource_descriptor: None,
1209                documentation: None,
1210            },
1211        );
1212
1213        // ListSchemasRequest — catalog_name with child_type = Schema
1214        messages.insert(
1215            "ListSchemasRequest".to_string(),
1216            MessageInfo {
1217                name: "ListSchemasRequest".to_string(),
1218                fields: vec![MessageField {
1219                    name: "catalog_name".to_string(),
1220                    json_name: None,
1221                    response_header: None,
1222                    request_header: None,
1223                    emit_unpopulated: false,
1224                    raw_body: false,
1225                    unified_type: UnifiedType {
1226                        base_type: BaseType::String,
1227                        is_optional: false,
1228                        is_repeated: false,
1229                    },
1230                    documentation: None,
1231                    oneof_variants: None,
1232                    field_behavior: vec![crate::google::api::FieldBehavior::Required],
1233                    is_sensitive: false,
1234                    resource_reference: Some(ResourceReference {
1235                        r#type: String::new(),
1236                        child_type: "example.io/Schema".to_string(),
1237                    }),
1238                }],
1239                resource_descriptor: None,
1240                documentation: None,
1241            },
1242        );
1243
1244        // ListTablesRequest — catalog_name and schema_name, both child_type = Table (flat API)
1245        messages.insert(
1246            "ListTablesRequest".to_string(),
1247            MessageInfo {
1248                name: "ListTablesRequest".to_string(),
1249                fields: vec![
1250                    MessageField {
1251                        name: "catalog_name".to_string(),
1252                        json_name: None,
1253                        response_header: None,
1254                        request_header: None,
1255                        emit_unpopulated: false,
1256                        raw_body: false,
1257                        unified_type: UnifiedType {
1258                            base_type: BaseType::String,
1259                            is_optional: false,
1260                            is_repeated: false,
1261                        },
1262                        documentation: None,
1263                        oneof_variants: None,
1264                        field_behavior: vec![crate::google::api::FieldBehavior::Required],
1265                        is_sensitive: false,
1266                        resource_reference: Some(ResourceReference {
1267                            r#type: String::new(),
1268                            child_type: "example.io/Table".to_string(),
1269                        }),
1270                    },
1271                    MessageField {
1272                        name: "schema_name".to_string(),
1273                        json_name: None,
1274                        response_header: None,
1275                        request_header: None,
1276                        emit_unpopulated: false,
1277                        raw_body: false,
1278                        unified_type: UnifiedType {
1279                            base_type: BaseType::String,
1280                            is_optional: false,
1281                            is_repeated: false,
1282                        },
1283                        documentation: None,
1284                        oneof_variants: None,
1285                        field_behavior: vec![crate::google::api::FieldBehavior::Required],
1286                        is_sensitive: false,
1287                        resource_reference: Some(ResourceReference {
1288                            r#type: String::new(),
1289                            child_type: "example.io/Table".to_string(),
1290                        }),
1291                    },
1292                ],
1293                resource_descriptor: None,
1294                documentation: None,
1295            },
1296        );
1297
1298        let metadata = CodeGenMetadata {
1299            messages,
1300            ..Default::default()
1301        };
1302
1303        let catalog_svc = ServiceInfo {
1304            name: "CatalogService".to_string(),
1305            package: "example.v1".to_string(),
1306            documentation: None,
1307            methods: vec![
1308                MethodMetadata {
1309                    service_name: "CatalogService".to_string(),
1310                    method_name: "ListCatalogs".to_string(),
1311                    input_type: "ListCatalogsRequest".to_string(),
1312                    output_type: "ListCatalogsResponse".to_string(),
1313                    server_streaming: false,
1314                    operation: None,
1315                    http_rule: HttpRule {
1316                        selector: "".to_string(),
1317                        pattern: Some(Pattern::Get("/catalogs".to_string())),
1318                        body: "".to_string(),
1319                        response_body: "".to_string(),
1320                        additional_bindings: vec![],
1321                    },
1322                    http_pattern: HttpPattern::parse("/catalogs"),
1323                    documentation: None,
1324                },
1325                MethodMetadata {
1326                    service_name: "CatalogService".to_string(),
1327                    method_name: "GetCatalog".to_string(),
1328                    input_type: "GetCatalogRequest".to_string(),
1329                    output_type: "Catalog".to_string(),
1330                    server_streaming: false,
1331                    operation: None,
1332                    http_rule: HttpRule {
1333                        selector: "".to_string(),
1334                        pattern: Some(Pattern::Get("/catalogs/{name}".to_string())),
1335                        body: "".to_string(),
1336                        response_body: "".to_string(),
1337                        additional_bindings: vec![],
1338                    },
1339                    http_pattern: HttpPattern::parse("/catalogs/{name}"),
1340                    documentation: None,
1341                },
1342            ],
1343        };
1344
1345        let schema_svc = ServiceInfo {
1346            name: "SchemaService".to_string(),
1347            package: "example.v1".to_string(),
1348            documentation: None,
1349            methods: vec![
1350                MethodMetadata {
1351                    service_name: "SchemaService".to_string(),
1352                    method_name: "ListSchemas".to_string(),
1353                    input_type: "ListSchemasRequest".to_string(),
1354                    output_type: "ListSchemasResponse".to_string(),
1355                    server_streaming: false,
1356                    operation: None,
1357                    http_rule: HttpRule {
1358                        selector: "".to_string(),
1359                        pattern: Some(Pattern::Get("/schemas".to_string())),
1360                        body: "".to_string(),
1361                        response_body: "".to_string(),
1362                        additional_bindings: vec![],
1363                    },
1364                    http_pattern: HttpPattern::parse("/schemas"),
1365                    documentation: None,
1366                },
1367                MethodMetadata {
1368                    service_name: "SchemaService".to_string(),
1369                    method_name: "GetSchema".to_string(),
1370                    input_type: "GetSchemaRequest".to_string(),
1371                    output_type: "Schema".to_string(),
1372                    server_streaming: false,
1373                    operation: None,
1374                    http_rule: HttpRule {
1375                        selector: "".to_string(),
1376                        pattern: Some(Pattern::Get("/schemas/{full_name}".to_string())),
1377                        body: "".to_string(),
1378                        response_body: "".to_string(),
1379                        additional_bindings: vec![],
1380                    },
1381                    http_pattern: HttpPattern::parse("/schemas/{full_name}"),
1382                    documentation: None,
1383                },
1384            ],
1385        };
1386
1387        let table_svc = ServiceInfo {
1388            name: "TableService".to_string(),
1389            package: "example.v1".to_string(),
1390            documentation: None,
1391            methods: vec![
1392                MethodMetadata {
1393                    service_name: "TableService".to_string(),
1394                    method_name: "ListTables".to_string(),
1395                    input_type: "ListTablesRequest".to_string(),
1396                    output_type: "ListTablesResponse".to_string(),
1397                    server_streaming: false,
1398                    operation: None,
1399                    http_rule: HttpRule {
1400                        selector: "".to_string(),
1401                        pattern: Some(Pattern::Get("/tables".to_string())),
1402                        body: "".to_string(),
1403                        response_body: "".to_string(),
1404                        additional_bindings: vec![],
1405                    },
1406                    http_pattern: HttpPattern::parse("/tables"),
1407                    documentation: None,
1408                },
1409                MethodMetadata {
1410                    service_name: "TableService".to_string(),
1411                    method_name: "GetTable".to_string(),
1412                    input_type: "GetTableRequest".to_string(),
1413                    output_type: "Table".to_string(),
1414                    server_streaming: false,
1415                    operation: None,
1416                    http_rule: HttpRule {
1417                        selector: "".to_string(),
1418                        pattern: Some(Pattern::Get("/tables/{full_name}".to_string())),
1419                        body: "".to_string(),
1420                        response_body: "".to_string(),
1421                        additional_bindings: vec![],
1422                    },
1423                    http_pattern: HttpPattern::parse("/tables/{full_name}"),
1424                    documentation: None,
1425                },
1426            ],
1427        };
1428
1429        (metadata, catalog_svc, schema_svc, table_svc)
1430    }
1431
1432    /// Build service plans from three-level fixture metadata for hierarchy testing.
1433    fn make_plans_from_fixture() -> (Vec<ServicePlan>, CodeGenMetadata) {
1434        let (metadata, catalog_svc, schema_svc, table_svc) = make_three_level_metadata();
1435        let mut plans = Vec::new();
1436        for svc in &[&catalog_svc, &schema_svc, &table_svc] {
1437            let plan = analyze_service(&metadata, svc).unwrap();
1438            plans.push(plan);
1439        }
1440        (plans, metadata)
1441    }
1442
1443    #[test]
1444    fn test_build_global_parent_map() {
1445        let (plans, metadata) = make_plans_from_fixture();
1446        let map = build_global_parent_map(&plans, &metadata);
1447
1448        // Catalog → Schema (from ListSchemasRequest.catalog_name with child_type=Schema)
1449        assert_eq!(
1450            map.get(&(
1451                "example.io/Catalog".to_string(),
1452                "example.io/Schema".to_string()
1453            )),
1454            Some(&"catalog_name".to_string()),
1455            "Catalog→Schema mapping missing"
1456        );
1457        // Schema → Table (from ListTablesRequest.schema_name with child_type=Table)
1458        assert_eq!(
1459            map.get(&(
1460                "example.io/Schema".to_string(),
1461                "example.io/Table".to_string()
1462            )),
1463            Some(&"schema_name".to_string()),
1464            "Schema→Table mapping missing"
1465        );
1466        // Catalog → Table (flat-API artifact from ListTablesRequest.catalog_name)
1467        assert_eq!(
1468            map.get(&(
1469                "example.io/Catalog".to_string(),
1470                "example.io/Table".to_string()
1471            )),
1472            Some(&"catalog_name".to_string()),
1473            "Catalog→Table flat-API mapping missing"
1474        );
1475    }
1476
1477    #[test]
1478    fn test_derive_ordered_hierarchy_three_levels() {
1479        let (plans, metadata) = make_plans_from_fixture();
1480        let map = build_global_parent_map(&plans, &metadata);
1481        let table_plan = plans
1482            .iter()
1483            .find(|p| p.service_name == "TableService")
1484            .unwrap();
1485        let hierarchy = derive_ordered_hierarchy(table_plan, &map, &metadata)
1486            .expect("no cycles in test fixture");
1487
1488        assert_eq!(hierarchy.len(), 2, "expected 2 ancestors for Table");
1489        // Root-first: catalog_name (depth 0) before schema_name (depth 1)
1490        assert_eq!(hierarchy[0].parent_field_name, "catalog_name");
1491        assert_eq!(hierarchy[1].parent_field_name, "schema_name");
1492        assert_eq!(hierarchy[0].parent_resource_type, "example.io/Catalog");
1493        assert_eq!(hierarchy[1].parent_resource_type, "example.io/Schema");
1494        assert_eq!(hierarchy[0].parent_singular, Some("catalog".to_string()));
1495        assert_eq!(hierarchy[1].parent_singular, Some("schema".to_string()));
1496        // child_resource_type is the managed resource (Table) for all entries
1497        assert!(
1498            hierarchy
1499                .iter()
1500                .all(|h| h.child_resource_type == "example.io/Table")
1501        );
1502    }
1503
1504    #[test]
1505    fn test_derive_ordered_hierarchy_two_levels() {
1506        let (plans, metadata) = make_plans_from_fixture();
1507        let map = build_global_parent_map(&plans, &metadata);
1508        let schema_plan = plans
1509            .iter()
1510            .find(|p| p.service_name == "SchemaService")
1511            .unwrap();
1512        let hierarchy = derive_ordered_hierarchy(schema_plan, &map, &metadata)
1513            .expect("no cycles in test fixture");
1514
1515        assert_eq!(hierarchy.len(), 1);
1516        assert_eq!(hierarchy[0].parent_field_name, "catalog_name");
1517        assert_eq!(hierarchy[0].parent_resource_type, "example.io/Catalog");
1518    }
1519
1520    #[test]
1521    fn test_derive_ordered_hierarchy_root_resource() {
1522        let (plans, metadata) = make_plans_from_fixture();
1523        let map = build_global_parent_map(&plans, &metadata);
1524        let catalog_plan = plans
1525            .iter()
1526            .find(|p| p.service_name == "CatalogService")
1527            .unwrap();
1528        let hierarchy = derive_ordered_hierarchy(catalog_plan, &map, &metadata)
1529            .expect("no cycles in test fixture");
1530
1531        assert!(
1532            hierarchy.is_empty(),
1533            "root resource should have empty hierarchy"
1534        );
1535    }
1536
1537    #[test]
1538    fn test_compute_depth_cycle_guard() {
1539        // A ↔ B mutual cycle: compute_depth should return Err, not panic or recurse infinitely.
1540        let mut map: GlobalParentMap = HashMap::new();
1541        map.insert(("A".to_string(), "B".to_string()), "a_name".to_string());
1542        map.insert(("B".to_string(), "A".to_string()), "b_name".to_string());
1543
1544        assert!(compute_depth("A", &map, &mut HashSet::new()).is_err());
1545        assert!(compute_depth("B", &map, &mut HashSet::new()).is_err());
1546    }
1547
1548    #[test]
1549    fn test_full_analyze_metadata_hierarchy_ordering() {
1550        let (metadata, catalog_svc, schema_svc, table_svc) = make_three_level_metadata();
1551        let mut services_map = HashMap::new();
1552        services_map.insert("CatalogService".to_string(), catalog_svc);
1553        services_map.insert("SchemaService".to_string(), schema_svc);
1554        services_map.insert("TableService".to_string(), table_svc);
1555        let full_metadata = CodeGenMetadata {
1556            messages: metadata.messages,
1557            services: services_map,
1558            ..Default::default()
1559        };
1560
1561        let plan = analyze_metadata(&full_metadata).unwrap();
1562        let table_svc_plan = plan
1563            .services
1564            .iter()
1565            .find(|s| s.service_name == "TableService")
1566            .expect("TableService plan not found");
1567
1568        assert_eq!(table_svc_plan.hierarchy.len(), 2);
1569        assert_eq!(
1570            table_svc_plan.hierarchy[0].parent_field_name,
1571            "catalog_name"
1572        );
1573        assert_eq!(table_svc_plan.hierarchy[1].parent_field_name, "schema_name");
1574    }
1575
1576    #[test]
1577    fn resource_accessor_params_nested_and_flat() {
1578        let (metadata, catalog_svc, schema_svc, table_svc) = make_three_level_metadata();
1579        let mut services_map = HashMap::new();
1580        services_map.insert("CatalogService".to_string(), catalog_svc);
1581        services_map.insert("SchemaService".to_string(), schema_svc);
1582        services_map.insert("TableService".to_string(), table_svc);
1583        let full_metadata = CodeGenMetadata {
1584            messages: metadata.messages,
1585            services: services_map,
1586            ..Default::default()
1587        };
1588
1589        let plan = analyze_metadata(&full_metadata).unwrap();
1590        let params = |name: &str| {
1591            plan.services
1592                .iter()
1593                .find(|s| s.service_name == name)
1594                .unwrap()
1595                .resource_accessor_params
1596                .clone()
1597        };
1598
1599        // Nested resource (annotation-driven): ancestors root-first + own leaf.
1600        assert_eq!(
1601            params("TableService"),
1602            Some(vec![
1603                "catalog_name".to_string(),
1604                "schema_name".to_string(),
1605                "table_name".to_string(),
1606            ])
1607        );
1608        // One ancestor.
1609        assert_eq!(
1610            params("SchemaService"),
1611            Some(vec!["catalog_name".to_string(), "schema_name".to_string()])
1612        );
1613        // Flat top-level resource: just its own name.
1614        assert_eq!(
1615            params("CatalogService"),
1616            Some(vec!["catalog_name".to_string()])
1617        );
1618    }
1619
1620    #[test]
1621    fn direct_children_via_accessor_param_prefix() {
1622        let (metadata, catalog_svc, schema_svc, table_svc) = make_three_level_metadata();
1623        let mut services_map = HashMap::new();
1624        services_map.insert("CatalogService".to_string(), catalog_svc);
1625        services_map.insert("SchemaService".to_string(), schema_svc);
1626        services_map.insert("TableService".to_string(), table_svc);
1627        let full_metadata = CodeGenMetadata {
1628            messages: metadata.messages,
1629            services: services_map,
1630            ..Default::default()
1631        };
1632
1633        let plan = analyze_metadata(&full_metadata).unwrap();
1634        let children = |name: &str| -> Vec<String> {
1635            plan.services
1636                .iter()
1637                .find(|s| s.service_name == name)
1638                .unwrap()
1639                .direct_children
1640                .iter()
1641                .map(|c| c.child_singular.clone())
1642                .collect()
1643        };
1644
1645        // Catalog → Schema (params extend by one); Catalog is NOT a direct parent of Table (extends
1646        // by two), so Table must not appear under Catalog.
1647        assert_eq!(children("CatalogService"), vec!["schema".to_string()]);
1648        // Schema → Table.
1649        assert_eq!(children("SchemaService"), vec!["table".to_string()]);
1650        // Table is a leaf: no children.
1651        assert!(children("TableService").is_empty());
1652
1653        // The child link carries enough to construct the child clients.
1654        let catalog = plan
1655            .services
1656            .iter()
1657            .find(|s| s.service_name == "CatalogService")
1658            .unwrap();
1659        let schema_link = &catalog.direct_children[0];
1660        // base_path comes from the child service name (here `SchemaService` → `schema`).
1661        let schema_base_path = plan
1662            .services
1663            .iter()
1664            .find(|s| s.service_name == "SchemaService")
1665            .unwrap()
1666            .base_path
1667            .clone();
1668        assert_eq!(schema_link.child_base_path, schema_base_path);
1669        assert_eq!(
1670            schema_link.child_accessor_params,
1671            vec!["catalog_name".to_string(), "schema_name".to_string()]
1672        );
1673    }
1674
1675    // The pagination-leak regression (a flat resource's List `page_token`/`max_results` no longer
1676    // bleed into its accessor params) is covered end-to-end by the golden snapshot test
1677    // (`tests/golden_integration.rs`), whose `example.bin` ListCatalogs request carries those very
1678    // fields. `is_standard_list_field` is unit-tested below.
1679
1680    #[test]
1681    fn standard_list_fields_recognized() {
1682        for f in [
1683            "page_token",
1684            "page_size",
1685            "max_results",
1686            "order_by",
1687            "filter",
1688        ] {
1689            assert!(
1690                is_standard_list_field(f),
1691                "{f} should be a standard list field"
1692            );
1693        }
1694        for f in ["name", "catalog_name", "schema_name"] {
1695            assert!(
1696                !is_standard_list_field(f),
1697                "{f} should NOT be a standard list field"
1698            );
1699        }
1700    }
1701}