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