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
460    Ok(MethodPlan {
461        metadata: method.clone(),
462        handler_function_name: strings::operation_to_method_name(&method.method_name),
463        http_method,
464        parameters,
465        has_response,
466        request_type,
467        output_resource_type,
468        http_pattern,
469        has_request_body,
470        needs_request_parts,
471        scoped_verb,
472        // Provisional: `shape` depends on the owning service's managed resources, which aren't
473        // known here. `analyze_service` overwrites this once `extract_managed_resources` has run.
474        shape: types::MethodShape::Unbound,
475        has_http_route: true,
476    })
477}
478
479/// Analyze a method that has no `google.api.http` annotation (the ConnectRPC-degenerate shape).
480///
481/// ConnectRPC dispatch sends the whole request message as the body and reads the whole response, so
482/// there is no path/query/body split and no URL template. Every non-`OUTPUT_ONLY` request field
483/// becomes a [`BodyField`], partitioned required-vs-optional purely from
484/// `google.api.field_behavior` — the same `BodyField` shape the REST builders consume, so the
485/// builder layer ([`crate::codegen::builder`]) is reused unchanged.
486///
487/// The HTTP-only fields of [`MethodPlan`] (`http_method`, `http_pattern`) are left empty/default and
488/// [`MethodPlan::has_http_route`] is `false`; the REST emitters skip such methods, so those fields
489/// are never read for them.
490fn analyze_routeless_method(
491    metadata: &CodeGenMetadata,
492    method: &MethodMetadata,
493) -> Result<MethodPlan> {
494    let input_fields = metadata.get_message_fields(&method.input_type);
495    let body_fields = routeless_body_fields(&input_fields);
496
497    // A routeless method carries no REST request shape. `Custom` keeps it off the REST collection /
498    // instance code paths (resource-scoped clients, pagination heuristics) while still flowing
499    // through the shared builder generator. The verb is irrelevant for Connect dispatch; `POST`
500    // is the conventional placeholder.
501    let request_type =
502        RequestType::Custom(crate::google::api::http_rule::Pattern::Post(String::new()));
503    let has_response = !method.output_type.is_empty() && !method.output_type.ends_with("Empty");
504
505    Ok(MethodPlan {
506        metadata: method.clone(),
507        handler_function_name: strings::operation_to_method_name(&method.method_name),
508        http_method: String::new(),
509        parameters: body_fields.into_iter().map(Into::into).collect(),
510        has_response,
511        request_type,
512        output_resource_type: None,
513        http_pattern: HttpPattern::default(),
514        // The request message is always carried as the body for Connect dispatch.
515        has_request_body: true,
516        needs_request_parts: false,
517        scoped_verb: None,
518        shape: types::MethodShape::Unbound,
519        has_http_route: false,
520    })
521}
522
523/// Classify a routeless request message's fields into [`BodyField`]s.
524///
525/// `OUTPUT_ONLY` fields are dropped (server-generated). Oneof fields are optional body fields with
526/// their variants preserved. Every other field is a body field whose `required` flag is taken from
527/// `google.api.field_behavior` REQUIRED — mirroring the `body: "*"` branch of
528/// [`extract_request_fields`], minus the path/query split that ConnectRPC has no notion of.
529fn routeless_body_fields(input_fields: &[MessageField]) -> Vec<BodyField> {
530    input_fields
531        .iter()
532        .filter(|field| !field.field_behavior.contains(&FieldBehavior::OutputOnly))
533        .map(body_field)
534        .collect()
535}
536
537/// The body field for a request field.
538///
539/// A oneof is always optional and keeps its variants. Otherwise `required` comes from
540/// `google.api.field_behavior` REQUIRED, and a non-required singular message is marked optional on
541/// the type so it becomes a `with_*(impl Into<Option<T>>)` setter (and an `Option<T>` with a
542/// `= None` default in FFI bindings) rather than a required argument. Scalars and collections are
543/// unaffected (their optionality is already correct).
544fn body_field(field: &MessageField) -> BodyField {
545    if matches!(field.unified_type.base_type, BaseType::OneOf(_)) {
546        return BodyField {
547            name: field.name.clone(),
548            field_type: field.unified_type.clone().optional(),
549            repeated: false,
550            required: false,
551            oneof_variants: field.oneof_variants.clone(),
552            documentation: field.documentation.clone(),
553        };
554    }
555
556    let required = field.field_behavior.contains(&FieldBehavior::Required);
557    let needs_optional_marker = !required
558        && !field.unified_type.is_repeated
559        && matches!(field.unified_type.base_type, BaseType::Message(_));
560    let field_type = if needs_optional_marker {
561        field.unified_type.clone().optional()
562    } else {
563        field.unified_type.clone()
564    };
565    BodyField {
566        name: field.name.clone(),
567        field_type,
568        repeated: field.unified_type.is_repeated,
569        required,
570        oneof_variants: None,
571        documentation: field.documentation.clone(),
572    }
573}
574
575/// Extract and classify request fields from an input message into path, query, and body buckets.
576///
577/// - Path parameters are matched against URL template parameters and ordered accordingly.
578/// - Fields annotated `OUTPUT_ONLY` are excluded entirely — they are server-generated and
579///   must not appear in request extractors or client request builders.
580/// - Fields matching the `body` spec (`"*"`, `""`, or a specific field name) become body fields.
581/// - All remaining fields become query parameters. Fields not explicitly marked optional
582///   via `UnifiedType.is_optional` are treated as required query parameters.
583/// - Oneof fields are always placed in the body as optional variants.
584fn extract_request_fields(
585    method: &MethodMetadata,
586    input_fields: &[MessageField],
587) -> Result<(Vec<PathParam>, Vec<QueryParam>, Vec<BodyField>)> {
588    let mut path_params = Vec::new();
589    let mut query_params = Vec::new();
590    let mut body_fields = Vec::new();
591
592    let path_param_names = method.http_pattern.parameter_names();
593    let body_spec = method.http_rule.body.as_str();
594
595    // Build an O(1) lookup map for input fields by name.
596    let fields_by_name: HashMap<&str, &MessageField> =
597        input_fields.iter().map(|f| (f.name.as_str(), f)).collect();
598
599    let mut processed_fields = HashSet::new();
600
601    // Add path parameters in URL template order. `parameter_names()` yields normalized flat field
602    // names from the URL template (segment-binding `=pattern` suffixes already stripped by the
603    // template parser; unsupported dotted placeholders are kept out of `parameters` entirely).
604    for path_param_name in path_param_names {
605        if let Some(field) = fields_by_name.get(path_param_name.as_str()) {
606            // Path params are always included even if OUTPUT_ONLY (they appear in the URL, not
607            // the request body), but in practice OUTPUT_ONLY fields should never be path params.
608            path_params.push(PathParam {
609                name: field.name.clone(),
610                field_type: field.unified_type.clone(),
611                documentation: field.documentation.clone(),
612            });
613            processed_fields.insert(field.name.as_str());
614        } else {
615            // Unresolved placeholder: warn rather than silently misrouting the field to query/body.
616            warn!(
617                "Method {}.{}: URL path placeholder `{}` does not match any request field; \
618                 the param will be omitted from the path",
619                method.service_name, method.method_name, path_param_name
620            );
621        }
622    }
623
624    // Classify remaining fields as body or query.
625    for field in input_fields {
626        let field_name = field.name.as_str();
627
628        if processed_fields.contains(field_name) {
629            continue;
630        }
631
632        // Skip OUTPUT_ONLY fields — they are server-generated and never provided by clients.
633        if field.field_behavior.contains(&FieldBehavior::OutputOnly) {
634            processed_fields.insert(field_name);
635            continue;
636        }
637
638        // Oneof fields are always body fields and always optional.
639        if matches!(field.unified_type.base_type, BaseType::OneOf(_)) {
640            body_fields.push(body_field(field));
641            processed_fields.insert(field_name);
642            continue;
643        }
644
645        let is_body = match body_spec {
646            "*" => true,
647            "" => false,
648            specific => specific == field_name,
649        };
650
651        if is_body {
652            body_fields.push(body_field(field));
653        } else {
654            query_params.push(QueryParam {
655                name: field.name.clone(),
656                json_name: field.json_name.clone(),
657                field_type: field.unified_type.clone(),
658                documentation: field.documentation.clone(),
659                resource_reference: field.resource_reference.clone(),
660            });
661        }
662        processed_fields.insert(field_name);
663    }
664
665    Ok((path_params, query_params, body_fields))
666}
667
668#[cfg(test)]
669mod tests {
670    use super::*;
671    use crate::google::api::{HttpRule, ResourceDescriptor, http_rule::Pattern};
672    use crate::parsing::types::UnifiedType;
673    use crate::parsing::{CodeGenMetadata, HttpPattern, MessageInfo, MethodMetadata, ServiceInfo};
674    use std::collections::HashMap;
675
676    fn make_metadata_with_catalog() -> CodeGenMetadata {
677        let catalog_resource = ResourceDescriptor {
678            r#type: "example.io/Catalog".to_string(),
679            pattern: vec!["catalogs/{catalog}".to_string()],
680            name_field: "name".to_string(),
681            history: 0,
682            plural: "catalogs".to_string(),
683            singular: "catalog".to_string(),
684            style: vec![],
685        };
686        let catalog_info = MessageInfo {
687            name: "Catalog".to_string(),
688            fields: vec![],
689            resource_descriptor: Some(catalog_resource),
690            documentation: None,
691        };
692        let mut messages = HashMap::new();
693        messages.insert("Catalog".to_string(), catalog_info);
694        CodeGenMetadata {
695            messages,
696            ..Default::default()
697        }
698    }
699
700    fn make_get_method() -> MethodMetadata {
701        MethodMetadata {
702            service_name: "CatalogService".to_string(),
703            method_name: "GetCatalog".to_string(),
704            input_type: "GetCatalogRequest".to_string(),
705            output_type: "Catalog".to_string(),
706            operation: None,
707            http_rule: HttpRule {
708                selector: "".to_string(),
709                pattern: Some(Pattern::Get("/catalogs/{name}".to_string())),
710                body: "".to_string(),
711                response_body: "".to_string(),
712                additional_bindings: vec![],
713            },
714            http_pattern: HttpPattern::parse("/catalogs/{name}"),
715            documentation: None,
716        }
717    }
718
719    #[test]
720    fn test_managed_resources_extraction() {
721        let metadata = make_metadata_with_catalog();
722        let service_info = ServiceInfo {
723            name: "CatalogService".to_string(),
724            package: "example.catalogs.v1".to_string(),
725            documentation: None,
726            methods: vec![make_get_method()],
727        };
728        let service_plan = analyze_service(&metadata, &service_info).unwrap();
729
730        assert_eq!(service_plan.managed_resources.len(), 1);
731        assert_eq!(service_plan.managed_resources[0].type_name, "Catalog");
732        assert_eq!(
733            service_plan.managed_resources[0].descriptor.r#type,
734            "example.io/Catalog"
735        );
736        assert_eq!(
737            service_plan.managed_resources[0].descriptor.singular,
738            "catalog"
739        );
740        assert_eq!(
741            service_plan.managed_resources[0].descriptor.plural,
742            "catalogs"
743        );
744    }
745
746    #[test]
747    fn test_no_duplicate_managed_resources() {
748        let metadata = make_metadata_with_catalog();
749        let update_method = MethodMetadata {
750            service_name: "CatalogService".to_string(),
751            method_name: "UpdateCatalog".to_string(),
752            input_type: "UpdateCatalogRequest".to_string(),
753            output_type: "Catalog".to_string(),
754            operation: None,
755            http_rule: HttpRule {
756                selector: "".to_string(),
757                pattern: Some(Pattern::Patch("/catalogs/{name}".to_string())),
758                body: "*".to_string(),
759                response_body: "".to_string(),
760                additional_bindings: vec![],
761            },
762            http_pattern: HttpPattern::parse("/catalogs/{name}"),
763            documentation: None,
764        };
765        let service_info = ServiceInfo {
766            name: "CatalogService".to_string(),
767            package: "example.catalogs.v1".to_string(),
768            documentation: None,
769            methods: vec![make_get_method(), update_method],
770        };
771        let service_plan = analyze_service(&metadata, &service_info).unwrap();
772
773        assert_eq!(service_plan.managed_resources.len(), 1);
774        assert_eq!(service_plan.managed_resources[0].type_name, "Catalog");
775    }
776
777    #[test]
778    fn test_analyze_method_missing_http_pattern_is_routeless() {
779        let metadata = CodeGenMetadata::default();
780        let method = MethodMetadata {
781            service_name: "SomeService".to_string(),
782            method_name: "SomeMethod".to_string(),
783            input_type: "".to_string(),
784            output_type: "".to_string(),
785            operation: None,
786            http_rule: HttpRule {
787                selector: "".to_string(),
788                pattern: None,
789                body: "".to_string(),
790                response_body: "".to_string(),
791                additional_bindings: vec![],
792            },
793            http_pattern: HttpPattern::parse(""),
794            documentation: None,
795        };
796        // Protocol-agnostic analysis still produces a plan, but flags it as having no HTTP route so
797        // the REST emitters skip it while the Connect emitter uses it.
798        let plan = analyze_method(&metadata, &method).unwrap();
799        assert!(!plan.has_http_route);
800        assert!(plan.http_method.is_empty());
801    }
802
803    #[test]
804    fn test_routeless_method_partitions_body_by_field_behavior() {
805        use crate::parsing::MessageInfo;
806
807        // A request with one REQUIRED field and one unmarked (optional) field, and NO HTTP rule.
808        let req = MessageInfo {
809            name: "DoThingRequest".to_string(),
810            fields: vec![
811                {
812                    let mut f = make_string_field("name", false);
813                    f.field_behavior = vec![FieldBehavior::Required];
814                    f
815                },
816                make_string_field("comment", false),
817            ],
818            resource_descriptor: None,
819            documentation: None,
820        };
821        let mut messages = HashMap::new();
822        messages.insert("DoThingRequest".to_string(), req);
823        let metadata = CodeGenMetadata {
824            messages,
825            ..Default::default()
826        };
827
828        let method = MethodMetadata {
829            service_name: "ThingService".to_string(),
830            method_name: "DoThing".to_string(),
831            input_type: "DoThingRequest".to_string(),
832            output_type: "DoThingResponse".to_string(),
833            operation: None,
834            http_rule: HttpRule {
835                selector: "".to_string(),
836                pattern: None, // no HTTP annotation
837                body: "".to_string(),
838                response_body: "".to_string(),
839                additional_bindings: vec![],
840            },
841            http_pattern: HttpPattern::default(),
842            documentation: None,
843        };
844
845        let plan = analyze_method(&metadata, &method).unwrap();
846        assert!(!plan.has_http_route);
847
848        // No path/query params: every field is a body field.
849        assert_eq!(plan.path_parameters().count(), 0);
850        assert_eq!(plan.query_parameters().count(), 0);
851        let body: Vec<_> = plan.body_fields().collect();
852        assert_eq!(body.len(), 2);
853
854        // Required vs optional follows field_behavior: `name` (REQUIRED) is required, `comment` not.
855        let name = body.iter().find(|b| b.name == "name").unwrap();
856        let comment = body.iter().find(|b| b.name == "comment").unwrap();
857        assert!(name.required, "REQUIRED field should be a constructor arg");
858        assert!(
859            !comment.required,
860            "unmarked field should be an optional with_* setter"
861        );
862        assert_eq!(plan.handler_function_name, "do_thing");
863        assert!(plan.has_response);
864    }
865
866    #[test]
867    fn test_routeless_method_drops_output_only_fields() {
868        use crate::parsing::MessageInfo;
869
870        let req = MessageInfo {
871            name: "MakeReq".to_string(),
872            fields: vec![make_string_field("input", false), {
873                let mut f = make_string_field("server_set", false);
874                f.field_behavior = vec![FieldBehavior::OutputOnly];
875                f
876            }],
877            resource_descriptor: None,
878            documentation: None,
879        };
880        let mut messages = HashMap::new();
881        messages.insert("MakeReq".to_string(), req);
882        let metadata = CodeGenMetadata {
883            messages,
884            ..Default::default()
885        };
886
887        let method = MethodMetadata {
888            service_name: "S".to_string(),
889            method_name: "Make".to_string(),
890            input_type: "MakeReq".to_string(),
891            output_type: "MakeResp".to_string(),
892            operation: None,
893            http_rule: HttpRule::default(),
894            http_pattern: HttpPattern::default(),
895            documentation: None,
896        };
897
898        let plan = analyze_method(&metadata, &method).unwrap();
899        let body: Vec<_> = plan.body_fields().collect();
900        assert_eq!(body.len(), 1, "OUTPUT_ONLY field must be dropped");
901        assert_eq!(body[0].name, "input");
902    }
903
904    // --- extract_request_fields unit tests ---
905
906    fn make_string_field(name: &str, optional: bool) -> MessageField {
907        use crate::parsing::types::BaseType;
908        MessageField {
909            name: name.to_string(),
910            json_name: None,
911            unified_type: UnifiedType {
912                base_type: BaseType::String,
913                is_optional: optional,
914                is_repeated: false,
915            },
916            documentation: None,
917            oneof_variants: None,
918            field_behavior: vec![],
919            is_sensitive: false,
920            resource_reference: None,
921        }
922    }
923
924    fn make_repeated_field(name: &str) -> MessageField {
925        use crate::parsing::types::BaseType;
926        MessageField {
927            name: name.to_string(),
928            json_name: None,
929            unified_type: UnifiedType {
930                base_type: BaseType::String,
931                is_optional: false,
932                is_repeated: true,
933            },
934            documentation: None,
935            oneof_variants: None,
936            field_behavior: vec![],
937            is_sensitive: false,
938            resource_reference: None,
939        }
940    }
941
942    fn make_method_with_pattern(pattern: Pattern, body: &str, path: &str) -> MethodMetadata {
943        MethodMetadata {
944            service_name: "Svc".to_string(),
945            method_name: "Method".to_string(),
946            input_type: "".to_string(),
947            output_type: "".to_string(),
948            operation: None,
949            http_rule: HttpRule {
950                selector: "".to_string(),
951                pattern: Some(pattern),
952                body: body.to_string(),
953                response_body: "".to_string(),
954                additional_bindings: vec![],
955            },
956            http_pattern: HttpPattern::parse(path),
957            documentation: None,
958        }
959    }
960
961    #[test]
962    fn test_extract_path_params_in_url_order() {
963        let method =
964            make_method_with_pattern(Pattern::Get("/a/{x}/b/{y}".to_string()), "", "/a/{x}/b/{y}");
965        let fields = vec![make_string_field("y", false), make_string_field("x", false)];
966        let (path, query, body) = extract_request_fields(&method, &fields).unwrap();
967        // Path params should be in URL order: x, y — not field declaration order
968        assert_eq!(path.len(), 2);
969        assert_eq!(path[0].name, "x");
970        assert_eq!(path[1].name, "y");
971        assert!(query.is_empty());
972        assert!(body.is_empty());
973    }
974
975    #[test]
976    fn test_extract_body_wildcard() {
977        let method = make_method_with_pattern(Pattern::Post("/items".to_string()), "*", "/items");
978        let fields = vec![
979            make_string_field("name", false),
980            make_string_field("description", true),
981        ];
982        let (path, query, body) = extract_request_fields(&method, &fields).unwrap();
983        assert!(path.is_empty());
984        assert!(query.is_empty());
985        assert_eq!(body.len(), 2);
986    }
987
988    #[test]
989    fn test_extract_specific_body_field() {
990        let method = make_method_with_pattern(
991            Pattern::Patch("/items/{name}".to_string()),
992            "payload",
993            "/items/{name}",
994        );
995        let fields = vec![
996            make_string_field("name", false),    // path
997            make_string_field("payload", false), // body (specific)
998            make_string_field("extra", true),    // query
999        ];
1000        let (path, query, body) = extract_request_fields(&method, &fields).unwrap();
1001        assert_eq!(path.len(), 1);
1002        assert_eq!(path[0].name, "name");
1003        assert_eq!(body.len(), 1);
1004        assert_eq!(body[0].name, "payload");
1005        assert_eq!(query.len(), 1);
1006        assert_eq!(query[0].name, "extra");
1007    }
1008
1009    #[test]
1010    fn test_extract_no_body_spec_all_query() {
1011        let method = make_method_with_pattern(Pattern::Get("/items".to_string()), "", "/items");
1012        let fields = vec![
1013            make_string_field("filter", true),
1014            make_string_field("page_size", true),
1015        ];
1016        let (path, query, body) = extract_request_fields(&method, &fields).unwrap();
1017        assert!(path.is_empty());
1018        assert_eq!(query.len(), 2);
1019        assert!(body.is_empty());
1020    }
1021
1022    #[test]
1023    fn test_extract_repeated_field_becomes_body_with_repeated_flag() {
1024        let method = make_method_with_pattern(Pattern::Post("/items".to_string()), "*", "/items");
1025        let fields = vec![make_repeated_field("tags")];
1026        let (_, _, body) = extract_request_fields(&method, &fields).unwrap();
1027        assert_eq!(body.len(), 1);
1028        assert!(body[0].repeated);
1029    }
1030
1031    // ── Cross-service hierarchy chain resolution tests ─────────────────────────────────────────
1032
1033    /// Build a three-level metadata fixture: Catalog → Schema → Table.
1034    ///
1035    /// Three services:
1036    /// - CatalogService: ListCatalogs (no child_type params), GetCatalog
1037    /// - SchemaService: ListSchemas (catalog_name with child_type = Schema), GetSchema
1038    /// - TableService: ListTables (catalog_name and schema_name both with child_type = Table), GetTable
1039    fn make_three_level_metadata() -> (CodeGenMetadata, ServiceInfo, ServiceInfo, ServiceInfo) {
1040        use crate::google::api::ResourceReference;
1041        use crate::parsing::types::BaseType;
1042
1043        let mut messages = HashMap::new();
1044
1045        // Catalog resource
1046        messages.insert(
1047            "Catalog".to_string(),
1048            MessageInfo {
1049                name: "Catalog".to_string(),
1050                fields: vec![],
1051                resource_descriptor: Some(ResourceDescriptor {
1052                    r#type: "example.io/Catalog".to_string(),
1053                    pattern: vec!["catalogs/{catalog}".to_string()],
1054                    name_field: "name".to_string(),
1055                    history: 0,
1056                    plural: "catalogs".to_string(),
1057                    singular: "catalog".to_string(),
1058                    style: vec![],
1059                }),
1060                documentation: None,
1061            },
1062        );
1063
1064        // Schema resource
1065        messages.insert(
1066            "Schema".to_string(),
1067            MessageInfo {
1068                name: "Schema".to_string(),
1069                fields: vec![],
1070                resource_descriptor: Some(ResourceDescriptor {
1071                    r#type: "example.io/Schema".to_string(),
1072                    pattern: vec!["schemas/{schema}".to_string()],
1073                    name_field: "name".to_string(),
1074                    history: 0,
1075                    plural: "schemas".to_string(),
1076                    singular: "schema".to_string(),
1077                    style: vec![],
1078                }),
1079                documentation: None,
1080            },
1081        );
1082
1083        // Table resource
1084        messages.insert(
1085            "Table".to_string(),
1086            MessageInfo {
1087                name: "Table".to_string(),
1088                fields: vec![],
1089                resource_descriptor: Some(ResourceDescriptor {
1090                    r#type: "example.io/Table".to_string(),
1091                    pattern: vec!["tables/{table}".to_string()],
1092                    name_field: "full_name".to_string(),
1093                    history: 0,
1094                    plural: "tables".to_string(),
1095                    singular: "table".to_string(),
1096                    style: vec![],
1097                }),
1098                documentation: None,
1099            },
1100        );
1101
1102        // ListCatalogsRequest — no child_type params
1103        messages.insert(
1104            "ListCatalogsRequest".to_string(),
1105            MessageInfo {
1106                name: "ListCatalogsRequest".to_string(),
1107                fields: vec![],
1108                resource_descriptor: None,
1109                documentation: None,
1110            },
1111        );
1112
1113        // ListSchemasRequest — catalog_name with child_type = Schema
1114        messages.insert(
1115            "ListSchemasRequest".to_string(),
1116            MessageInfo {
1117                name: "ListSchemasRequest".to_string(),
1118                fields: vec![MessageField {
1119                    name: "catalog_name".to_string(),
1120                    json_name: None,
1121                    unified_type: UnifiedType {
1122                        base_type: BaseType::String,
1123                        is_optional: false,
1124                        is_repeated: false,
1125                    },
1126                    documentation: None,
1127                    oneof_variants: None,
1128                    field_behavior: vec![crate::google::api::FieldBehavior::Required],
1129                    is_sensitive: false,
1130                    resource_reference: Some(ResourceReference {
1131                        r#type: String::new(),
1132                        child_type: "example.io/Schema".to_string(),
1133                    }),
1134                }],
1135                resource_descriptor: None,
1136                documentation: None,
1137            },
1138        );
1139
1140        // ListTablesRequest — catalog_name and schema_name, both child_type = Table (flat API)
1141        messages.insert(
1142            "ListTablesRequest".to_string(),
1143            MessageInfo {
1144                name: "ListTablesRequest".to_string(),
1145                fields: vec![
1146                    MessageField {
1147                        name: "catalog_name".to_string(),
1148                        json_name: None,
1149                        unified_type: UnifiedType {
1150                            base_type: BaseType::String,
1151                            is_optional: false,
1152                            is_repeated: false,
1153                        },
1154                        documentation: None,
1155                        oneof_variants: None,
1156                        field_behavior: vec![crate::google::api::FieldBehavior::Required],
1157                        is_sensitive: false,
1158                        resource_reference: Some(ResourceReference {
1159                            r#type: String::new(),
1160                            child_type: "example.io/Table".to_string(),
1161                        }),
1162                    },
1163                    MessageField {
1164                        name: "schema_name".to_string(),
1165                        json_name: None,
1166                        unified_type: UnifiedType {
1167                            base_type: BaseType::String,
1168                            is_optional: false,
1169                            is_repeated: false,
1170                        },
1171                        documentation: None,
1172                        oneof_variants: None,
1173                        field_behavior: vec![crate::google::api::FieldBehavior::Required],
1174                        is_sensitive: false,
1175                        resource_reference: Some(ResourceReference {
1176                            r#type: String::new(),
1177                            child_type: "example.io/Table".to_string(),
1178                        }),
1179                    },
1180                ],
1181                resource_descriptor: None,
1182                documentation: None,
1183            },
1184        );
1185
1186        let metadata = CodeGenMetadata {
1187            messages,
1188            ..Default::default()
1189        };
1190
1191        let catalog_svc = ServiceInfo {
1192            name: "CatalogService".to_string(),
1193            package: "example.v1".to_string(),
1194            documentation: None,
1195            methods: vec![
1196                MethodMetadata {
1197                    service_name: "CatalogService".to_string(),
1198                    method_name: "ListCatalogs".to_string(),
1199                    input_type: "ListCatalogsRequest".to_string(),
1200                    output_type: "ListCatalogsResponse".to_string(),
1201                    operation: None,
1202                    http_rule: HttpRule {
1203                        selector: "".to_string(),
1204                        pattern: Some(Pattern::Get("/catalogs".to_string())),
1205                        body: "".to_string(),
1206                        response_body: "".to_string(),
1207                        additional_bindings: vec![],
1208                    },
1209                    http_pattern: HttpPattern::parse("/catalogs"),
1210                    documentation: None,
1211                },
1212                MethodMetadata {
1213                    service_name: "CatalogService".to_string(),
1214                    method_name: "GetCatalog".to_string(),
1215                    input_type: "GetCatalogRequest".to_string(),
1216                    output_type: "Catalog".to_string(),
1217                    operation: None,
1218                    http_rule: HttpRule {
1219                        selector: "".to_string(),
1220                        pattern: Some(Pattern::Get("/catalogs/{name}".to_string())),
1221                        body: "".to_string(),
1222                        response_body: "".to_string(),
1223                        additional_bindings: vec![],
1224                    },
1225                    http_pattern: HttpPattern::parse("/catalogs/{name}"),
1226                    documentation: None,
1227                },
1228            ],
1229        };
1230
1231        let schema_svc = ServiceInfo {
1232            name: "SchemaService".to_string(),
1233            package: "example.v1".to_string(),
1234            documentation: None,
1235            methods: vec![
1236                MethodMetadata {
1237                    service_name: "SchemaService".to_string(),
1238                    method_name: "ListSchemas".to_string(),
1239                    input_type: "ListSchemasRequest".to_string(),
1240                    output_type: "ListSchemasResponse".to_string(),
1241                    operation: None,
1242                    http_rule: HttpRule {
1243                        selector: "".to_string(),
1244                        pattern: Some(Pattern::Get("/schemas".to_string())),
1245                        body: "".to_string(),
1246                        response_body: "".to_string(),
1247                        additional_bindings: vec![],
1248                    },
1249                    http_pattern: HttpPattern::parse("/schemas"),
1250                    documentation: None,
1251                },
1252                MethodMetadata {
1253                    service_name: "SchemaService".to_string(),
1254                    method_name: "GetSchema".to_string(),
1255                    input_type: "GetSchemaRequest".to_string(),
1256                    output_type: "Schema".to_string(),
1257                    operation: None,
1258                    http_rule: HttpRule {
1259                        selector: "".to_string(),
1260                        pattern: Some(Pattern::Get("/schemas/{full_name}".to_string())),
1261                        body: "".to_string(),
1262                        response_body: "".to_string(),
1263                        additional_bindings: vec![],
1264                    },
1265                    http_pattern: HttpPattern::parse("/schemas/{full_name}"),
1266                    documentation: None,
1267                },
1268            ],
1269        };
1270
1271        let table_svc = ServiceInfo {
1272            name: "TableService".to_string(),
1273            package: "example.v1".to_string(),
1274            documentation: None,
1275            methods: vec![
1276                MethodMetadata {
1277                    service_name: "TableService".to_string(),
1278                    method_name: "ListTables".to_string(),
1279                    input_type: "ListTablesRequest".to_string(),
1280                    output_type: "ListTablesResponse".to_string(),
1281                    operation: None,
1282                    http_rule: HttpRule {
1283                        selector: "".to_string(),
1284                        pattern: Some(Pattern::Get("/tables".to_string())),
1285                        body: "".to_string(),
1286                        response_body: "".to_string(),
1287                        additional_bindings: vec![],
1288                    },
1289                    http_pattern: HttpPattern::parse("/tables"),
1290                    documentation: None,
1291                },
1292                MethodMetadata {
1293                    service_name: "TableService".to_string(),
1294                    method_name: "GetTable".to_string(),
1295                    input_type: "GetTableRequest".to_string(),
1296                    output_type: "Table".to_string(),
1297                    operation: None,
1298                    http_rule: HttpRule {
1299                        selector: "".to_string(),
1300                        pattern: Some(Pattern::Get("/tables/{full_name}".to_string())),
1301                        body: "".to_string(),
1302                        response_body: "".to_string(),
1303                        additional_bindings: vec![],
1304                    },
1305                    http_pattern: HttpPattern::parse("/tables/{full_name}"),
1306                    documentation: None,
1307                },
1308            ],
1309        };
1310
1311        (metadata, catalog_svc, schema_svc, table_svc)
1312    }
1313
1314    /// Build service plans from three-level fixture metadata for hierarchy testing.
1315    fn make_plans_from_fixture() -> (Vec<ServicePlan>, CodeGenMetadata) {
1316        let (metadata, catalog_svc, schema_svc, table_svc) = make_three_level_metadata();
1317        let mut plans = Vec::new();
1318        for svc in &[&catalog_svc, &schema_svc, &table_svc] {
1319            let plan = analyze_service(&metadata, svc).unwrap();
1320            plans.push(plan);
1321        }
1322        (plans, metadata)
1323    }
1324
1325    #[test]
1326    fn test_build_global_parent_map() {
1327        let (plans, metadata) = make_plans_from_fixture();
1328        let map = build_global_parent_map(&plans, &metadata);
1329
1330        // Catalog → Schema (from ListSchemasRequest.catalog_name with child_type=Schema)
1331        assert_eq!(
1332            map.get(&(
1333                "example.io/Catalog".to_string(),
1334                "example.io/Schema".to_string()
1335            )),
1336            Some(&"catalog_name".to_string()),
1337            "Catalog→Schema mapping missing"
1338        );
1339        // Schema → Table (from ListTablesRequest.schema_name with child_type=Table)
1340        assert_eq!(
1341            map.get(&(
1342                "example.io/Schema".to_string(),
1343                "example.io/Table".to_string()
1344            )),
1345            Some(&"schema_name".to_string()),
1346            "Schema→Table mapping missing"
1347        );
1348        // Catalog → Table (flat-API artifact from ListTablesRequest.catalog_name)
1349        assert_eq!(
1350            map.get(&(
1351                "example.io/Catalog".to_string(),
1352                "example.io/Table".to_string()
1353            )),
1354            Some(&"catalog_name".to_string()),
1355            "Catalog→Table flat-API mapping missing"
1356        );
1357    }
1358
1359    #[test]
1360    fn test_derive_ordered_hierarchy_three_levels() {
1361        let (plans, metadata) = make_plans_from_fixture();
1362        let map = build_global_parent_map(&plans, &metadata);
1363        let table_plan = plans
1364            .iter()
1365            .find(|p| p.service_name == "TableService")
1366            .unwrap();
1367        let hierarchy = derive_ordered_hierarchy(table_plan, &map, &metadata)
1368            .expect("no cycles in test fixture");
1369
1370        assert_eq!(hierarchy.len(), 2, "expected 2 ancestors for Table");
1371        // Root-first: catalog_name (depth 0) before schema_name (depth 1)
1372        assert_eq!(hierarchy[0].parent_field_name, "catalog_name");
1373        assert_eq!(hierarchy[1].parent_field_name, "schema_name");
1374        assert_eq!(hierarchy[0].parent_resource_type, "example.io/Catalog");
1375        assert_eq!(hierarchy[1].parent_resource_type, "example.io/Schema");
1376        assert_eq!(hierarchy[0].parent_singular, Some("catalog".to_string()));
1377        assert_eq!(hierarchy[1].parent_singular, Some("schema".to_string()));
1378        // child_resource_type is the managed resource (Table) for all entries
1379        assert!(
1380            hierarchy
1381                .iter()
1382                .all(|h| h.child_resource_type == "example.io/Table")
1383        );
1384    }
1385
1386    #[test]
1387    fn test_derive_ordered_hierarchy_two_levels() {
1388        let (plans, metadata) = make_plans_from_fixture();
1389        let map = build_global_parent_map(&plans, &metadata);
1390        let schema_plan = plans
1391            .iter()
1392            .find(|p| p.service_name == "SchemaService")
1393            .unwrap();
1394        let hierarchy = derive_ordered_hierarchy(schema_plan, &map, &metadata)
1395            .expect("no cycles in test fixture");
1396
1397        assert_eq!(hierarchy.len(), 1);
1398        assert_eq!(hierarchy[0].parent_field_name, "catalog_name");
1399        assert_eq!(hierarchy[0].parent_resource_type, "example.io/Catalog");
1400    }
1401
1402    #[test]
1403    fn test_derive_ordered_hierarchy_root_resource() {
1404        let (plans, metadata) = make_plans_from_fixture();
1405        let map = build_global_parent_map(&plans, &metadata);
1406        let catalog_plan = plans
1407            .iter()
1408            .find(|p| p.service_name == "CatalogService")
1409            .unwrap();
1410        let hierarchy = derive_ordered_hierarchy(catalog_plan, &map, &metadata)
1411            .expect("no cycles in test fixture");
1412
1413        assert!(
1414            hierarchy.is_empty(),
1415            "root resource should have empty hierarchy"
1416        );
1417    }
1418
1419    #[test]
1420    fn test_compute_depth_cycle_guard() {
1421        // A ↔ B mutual cycle: compute_depth should return Err, not panic or recurse infinitely.
1422        let mut map: GlobalParentMap = HashMap::new();
1423        map.insert(("A".to_string(), "B".to_string()), "a_name".to_string());
1424        map.insert(("B".to_string(), "A".to_string()), "b_name".to_string());
1425
1426        assert!(compute_depth("A", &map, &mut HashSet::new()).is_err());
1427        assert!(compute_depth("B", &map, &mut HashSet::new()).is_err());
1428    }
1429
1430    #[test]
1431    fn test_full_analyze_metadata_hierarchy_ordering() {
1432        let (metadata, catalog_svc, schema_svc, table_svc) = make_three_level_metadata();
1433        let mut services_map = HashMap::new();
1434        services_map.insert("CatalogService".to_string(), catalog_svc);
1435        services_map.insert("SchemaService".to_string(), schema_svc);
1436        services_map.insert("TableService".to_string(), table_svc);
1437        let full_metadata = CodeGenMetadata {
1438            messages: metadata.messages,
1439            services: services_map,
1440            ..Default::default()
1441        };
1442
1443        let plan = analyze_metadata(&full_metadata).unwrap();
1444        let table_svc_plan = plan
1445            .services
1446            .iter()
1447            .find(|s| s.service_name == "TableService")
1448            .expect("TableService plan not found");
1449
1450        assert_eq!(table_svc_plan.hierarchy.len(), 2);
1451        assert_eq!(
1452            table_svc_plan.hierarchy[0].parent_field_name,
1453            "catalog_name"
1454        );
1455        assert_eq!(table_svc_plan.hierarchy[1].parent_field_name, "schema_name");
1456    }
1457
1458    #[test]
1459    fn resource_accessor_params_nested_and_flat() {
1460        let (metadata, catalog_svc, schema_svc, table_svc) = make_three_level_metadata();
1461        let mut services_map = HashMap::new();
1462        services_map.insert("CatalogService".to_string(), catalog_svc);
1463        services_map.insert("SchemaService".to_string(), schema_svc);
1464        services_map.insert("TableService".to_string(), table_svc);
1465        let full_metadata = CodeGenMetadata {
1466            messages: metadata.messages,
1467            services: services_map,
1468            ..Default::default()
1469        };
1470
1471        let plan = analyze_metadata(&full_metadata).unwrap();
1472        let params = |name: &str| {
1473            plan.services
1474                .iter()
1475                .find(|s| s.service_name == name)
1476                .unwrap()
1477                .resource_accessor_params
1478                .clone()
1479        };
1480
1481        // Nested resource (annotation-driven): ancestors root-first + own leaf.
1482        assert_eq!(
1483            params("TableService"),
1484            Some(vec![
1485                "catalog_name".to_string(),
1486                "schema_name".to_string(),
1487                "table_name".to_string(),
1488            ])
1489        );
1490        // One ancestor.
1491        assert_eq!(
1492            params("SchemaService"),
1493            Some(vec!["catalog_name".to_string(), "schema_name".to_string()])
1494        );
1495        // Flat top-level resource: just its own name.
1496        assert_eq!(
1497            params("CatalogService"),
1498            Some(vec!["catalog_name".to_string()])
1499        );
1500    }
1501
1502    #[test]
1503    fn direct_children_via_accessor_param_prefix() {
1504        let (metadata, catalog_svc, schema_svc, table_svc) = make_three_level_metadata();
1505        let mut services_map = HashMap::new();
1506        services_map.insert("CatalogService".to_string(), catalog_svc);
1507        services_map.insert("SchemaService".to_string(), schema_svc);
1508        services_map.insert("TableService".to_string(), table_svc);
1509        let full_metadata = CodeGenMetadata {
1510            messages: metadata.messages,
1511            services: services_map,
1512            ..Default::default()
1513        };
1514
1515        let plan = analyze_metadata(&full_metadata).unwrap();
1516        let children = |name: &str| -> Vec<String> {
1517            plan.services
1518                .iter()
1519                .find(|s| s.service_name == name)
1520                .unwrap()
1521                .direct_children
1522                .iter()
1523                .map(|c| c.child_singular.clone())
1524                .collect()
1525        };
1526
1527        // Catalog → Schema (params extend by one); Catalog is NOT a direct parent of Table (extends
1528        // by two), so Table must not appear under Catalog.
1529        assert_eq!(children("CatalogService"), vec!["schema".to_string()]);
1530        // Schema → Table.
1531        assert_eq!(children("SchemaService"), vec!["table".to_string()]);
1532        // Table is a leaf: no children.
1533        assert!(children("TableService").is_empty());
1534
1535        // The child link carries enough to construct the child clients.
1536        let catalog = plan
1537            .services
1538            .iter()
1539            .find(|s| s.service_name == "CatalogService")
1540            .unwrap();
1541        let schema_link = &catalog.direct_children[0];
1542        // base_path comes from the child service name (here `SchemaService` → `schema`).
1543        let schema_base_path = plan
1544            .services
1545            .iter()
1546            .find(|s| s.service_name == "SchemaService")
1547            .unwrap()
1548            .base_path
1549            .clone();
1550        assert_eq!(schema_link.child_base_path, schema_base_path);
1551        assert_eq!(
1552            schema_link.child_accessor_params,
1553            vec!["catalog_name".to_string(), "schema_name".to_string()]
1554        );
1555    }
1556
1557    // The pagination-leak regression (a flat resource's List `page_token`/`max_results` no longer
1558    // bleed into its accessor params) is covered end-to-end by the golden snapshot test
1559    // (`tests/golden_integration.rs`), whose `example.bin` ListCatalogs request carries those very
1560    // fields. `is_standard_list_field` is unit-tested below.
1561
1562    #[test]
1563    fn standard_list_fields_recognized() {
1564        for f in [
1565            "page_token",
1566            "page_size",
1567            "max_results",
1568            "order_by",
1569            "filter",
1570        ] {
1571            assert!(
1572                is_standard_list_field(f),
1573                "{f} should be a standard list field"
1574            );
1575        }
1576        for f in ["name", "catalog_name", "schema_name"] {
1577            assert!(
1578                !is_standard_list_field(f),
1579                "{f} should NOT be a standard list field"
1580            );
1581        }
1582    }
1583}