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