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