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, 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/// Methods with missing HTTP annotations are excluded from the plan and recorded in
63/// [`GenerationPlan::skipped_methods`] so callers can distinguish "zero methods generated"
64/// from "all methods were silently dropped".
65///
66/// Hierarchy derivation uses a two-phase cross-service algorithm:
67/// 1. Build all service plans (hierarchy empty).
68/// 2. Construct a global parent map across all services, then assign depth-sorted hierarchies.
69pub fn analyze_metadata(metadata: &CodeGenMetadata) -> Result<GenerationPlan> {
70    // Phase 1: analyze all services without hierarchy.
71    //
72    // `metadata.services` is a `HashMap`, whose iteration order is non-deterministic. Sort by
73    // service name first so the resulting plan order (and thus emitted module declarations and the
74    // global parent map's insertion order) is reproducible across runs.
75    let mut service_infos: Vec<_> = metadata.services.values().collect();
76    service_infos.sort_by(|a, b| a.name.cmp(&b.name));
77
78    let mut plans = Vec::new();
79    let mut skipped_methods = Vec::new();
80    for service_info in service_infos {
81        let (plan, skipped) = analyze_service(metadata, service_info)?;
82        plans.push(plan);
83        skipped_methods.extend(skipped);
84    }
85
86    // Phase 2: build the cross-service global parent map, then assign depth-sorted hierarchies
87    // and (once the hierarchy is known) the resource accessor param list.
88    let global_map = build_global_parent_map(&plans, metadata);
89    let mut services = plans
90        .into_iter()
91        .map(|mut plan| {
92            plan.hierarchy = derive_ordered_hierarchy(&plan, &global_map, metadata)?;
93            plan.resource_accessor_params = derive_resource_accessor_params(&plan);
94            Ok(plan)
95        })
96        .collect::<Result<Vec<_>>>()?;
97
98    // Phase 2b: derive direct children. This needs every service's accessor params (filled above),
99    // so it runs as a separate sub-pass over the now-complete set.
100    assign_direct_children(&mut services);
101
102    Ok(GenerationPlan {
103        services,
104        skipped_methods,
105    })
106}
107
108// ── Cross-service hierarchy resolution ────────────────────────────────────────────────────────
109
110/// Maps `(parent_resource_type, child_resource_type)` → proto field name that carries the
111/// parent identifier on the child service's List request (e.g. `"catalog_name"`).
112type GlobalParentMap = HashMap<(String, String), String>;
113
114/// Build a map of immediate-parent relationships by scanning every service's List method query
115/// params for `child_type`-annotated fields.
116///
117/// **Key semantic filter:** only params where `child_type == this service's managed resource type`
118/// are recorded. This ensures that `catalog_name` on `ListTablesRequest` (child_type = Table) is
119/// recorded as `(Catalog, Table)`, and `schema_name` as `(Schema, Table)` — but when walking
120/// Schema's own List method, `catalog_name` (child_type = Schema) is recorded as `(Catalog, Schema)`.
121/// The chain `Catalog → Schema → Table` is then reconstructable via depth analysis.
122fn build_global_parent_map(plans: &[ServicePlan], metadata: &CodeGenMetadata) -> GlobalParentMap {
123    let mut map: GlobalParentMap = HashMap::new();
124
125    for plan in plans {
126        let managed_type = match plan.managed_resources.first() {
127            Some(r) => &r.descriptor.r#type,
128            None => continue,
129        };
130        if managed_type.is_empty() {
131            continue;
132        }
133
134        for method in &plan.methods {
135            if method.request_type != RequestType::List {
136                continue;
137            }
138            for param in method.query_parameters() {
139                let Some(ref rr) = param.resource_reference else {
140                    continue;
141                };
142                // Only record when child_type matches this service's own managed resource type.
143                if rr.child_type != *managed_type {
144                    continue;
145                }
146                if let Some(parent_type) = resolve_parent_type_from_field(&param.name, metadata) {
147                    map.entry((parent_type, managed_type.clone()))
148                        .or_insert_with(|| param.name.clone());
149                }
150            }
151        }
152    }
153
154    map
155}
156
157/// Derive the ordered ancestor chain for a service's managed resource using the global parent map.
158///
159/// Returns entries sorted **root-first** (shallowest ancestor first), so iterating them gives
160/// the correct left-to-right param order (e.g. `[catalog_name, schema_name]` for a Table service).
161///
162/// Returns `Err` if a cycle is detected in the resource hierarchy annotations.
163fn derive_ordered_hierarchy(
164    plan: &ServicePlan,
165    global_map: &GlobalParentMap,
166    metadata: &CodeGenMetadata,
167) -> Result<Vec<ResourceHierarchy>> {
168    let managed_type = match plan.managed_resources.first() {
169        Some(r) => r.descriptor.r#type.clone(),
170        None => return Ok(vec![]),
171    };
172
173    // Collect all (parent_resource_type, parent_field_name) entries for this managed resource.
174    let mut ancestors: Vec<(usize, String, String)> = global_map
175        .iter()
176        .filter(|((_, child), _)| child == &managed_type)
177        .map(|((parent_type, _), field_name)| {
178            let depth = compute_depth(parent_type, global_map, &mut HashSet::new())?;
179            Ok((depth, parent_type.clone(), field_name.clone()))
180        })
181        .collect::<Result<Vec<_>>>()?;
182
183    if ancestors.is_empty() {
184        return Ok(vec![]);
185    }
186
187    // Sort root-first (ascending depth).
188    ancestors.sort_by_key(|(depth, _, _)| *depth);
189
190    Ok(ancestors
191        .into_iter()
192        .map(|(_, parent_type, field_name)| {
193            let parent_singular = field_name.strip_suffix("_name").and_then(|stem| {
194                metadata
195                    .resource_from_singular(stem)
196                    .map(|_| stem.to_string())
197            });
198            ResourceHierarchy {
199                child_resource_type: managed_type.clone(),
200                parent_resource_type: parent_type,
201                parent_field_name: field_name,
202                parent_singular,
203            }
204        })
205        .collect())
206}
207
208/// Compute the depth of a resource type in the parent chain (0 = root, no parent in map).
209///
210/// Returns `Err` if a cycle is detected in the resource hierarchy annotations.
211fn compute_depth(
212    resource_type: &str,
213    map: &GlobalParentMap,
214    visited: &mut HashSet<String>,
215) -> Result<usize> {
216    if !visited.insert(resource_type.to_string()) {
217        return Err(crate::Error::Build(format!(
218            "Cycle detected in resource hierarchy at type: {resource_type}"
219        )));
220    }
221    let parent = map
222        .iter()
223        .find(|((_, child), _)| child == resource_type)
224        .map(|((parent, _), _)| parent.clone());
225
226    match parent {
227        None => Ok(0),
228        Some(parent_type) => Ok(1 + compute_depth(&parent_type, map, visited)?),
229    }
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.
377///
378/// Returns the plan and a list of methods that were skipped due to incomplete metadata.
379fn analyze_service(
380    metadata: &CodeGenMetadata,
381    info: &ServiceInfo,
382) -> Result<(ServicePlan, Vec<SkippedMethod>)> {
383    let handler_name = strings::service_to_handler_name(&info.name);
384    let base_path = strings::service_to_base_path(&info.name);
385
386    let mut method_plans = Vec::new();
387    let mut skipped = Vec::new();
388
389    for method in &info.methods {
390        if let Some(method_plan) = analyze_method(metadata, method)? {
391            method_plans.push(method_plan);
392        } else {
393            warn!(
394                "Skipping method {}.{} - incomplete metadata",
395                info.name, method.method_name
396            );
397            skipped.push(SkippedMethod {
398                service_name: info.name.clone(),
399                method_name: method.method_name.clone(),
400                reason: "missing HTTP annotation".to_string(),
401            });
402        }
403    }
404
405    let managed_resources = types::extract_managed_resources(metadata, &method_plans);
406
407    // Second pass: now that we know whether this service manages a resource, classify each method's
408    // shape (collection / instance / unbound). `analyze_method` couldn't do this — it has no view of
409    // the service's resources.
410    let service_has_resource = !managed_resources.is_empty();
411    for plan in &mut method_plans {
412        plan.shape = types::method_shape(
413            &plan.request_type,
414            &plan.metadata.method_name,
415            service_has_resource,
416        );
417    }
418
419    Ok((
420        ServicePlan {
421            service_name: info.name.clone(),
422            handler_name,
423            base_path,
424            package: info.package.clone(),
425            methods: method_plans,
426            managed_resources,
427            documentation: info.documentation.clone(),
428            hierarchy: vec![],              // filled in Phase 2 of analyze_metadata
429            resource_accessor_params: None, // filled in Phase 2 of analyze_metadata
430            direct_children: vec![],        // filled in Phase 2b of analyze_metadata
431        },
432        skipped,
433    ))
434}
435
436/// Analyze a single method and create a method plan.
437///
438/// Returns `None` if the method has incomplete metadata (e.g., missing HTTP annotation).
439pub(crate) fn analyze_method(
440    metadata: &CodeGenMetadata,
441    method: &MethodMetadata,
442) -> Result<Option<MethodPlan>> {
443    let http_method = match method.http_method() {
444        Some(m) => m.to_string(),
445        None => {
446            warn!(
447                "Method {}.{} missing HTTP info",
448                method.service_name, method.method_name
449            );
450            return Ok(None);
451        }
452    };
453
454    let planner = MethodPlanner::try_new(method, metadata)?;
455    let request_type = planner.request_type();
456    let has_response = planner.has_response();
457    let output_resource_type = planner.output_resource_type();
458    let http_pattern = planner.into_http_pattern();
459
460    let input_fields = metadata.get_message_fields(&method.input_type);
461    let (path_params, query_params, body_fields) = extract_request_fields(method, &input_fields)?;
462
463    let parameters = path_params
464        .into_iter()
465        .map(Into::into)
466        .chain(query_params.into_iter().map(Into::into))
467        .chain(body_fields.into_iter().map(Into::into))
468        .collect();
469
470    let has_request_body = types::request_has_body(&request_type);
471    let needs_request_parts = types::request_needs_request_parts(&request_type);
472    let scoped_verb = types::scoped_verb(&request_type);
473
474    Ok(Some(MethodPlan {
475        metadata: method.clone(),
476        handler_function_name: method.method_name.to_case(Case::Snake),
477        http_method,
478        parameters,
479        has_response,
480        request_type,
481        output_resource_type,
482        http_pattern,
483        has_request_body,
484        needs_request_parts,
485        scoped_verb,
486        // Provisional: `shape` depends on the owning service's managed resources, which aren't
487        // known here. `analyze_service` overwrites this once `extract_managed_resources` has run.
488        shape: types::MethodShape::Unbound,
489    }))
490}
491
492/// Extract and classify request fields from an input message into path, query, and body buckets.
493///
494/// - Path parameters are matched against URL template parameters and ordered accordingly.
495/// - Fields annotated `OUTPUT_ONLY` are excluded entirely — they are server-generated and
496///   must not appear in request extractors or client request builders.
497/// - Fields matching the `body` spec (`"*"`, `""`, or a specific field name) become body fields.
498/// - All remaining fields become query parameters. Fields not explicitly marked optional
499///   via `UnifiedType.is_optional` are treated as required query parameters.
500/// - Oneof fields are always placed in the body as optional variants.
501fn extract_request_fields(
502    method: &MethodMetadata,
503    input_fields: &[MessageField],
504) -> Result<(Vec<PathParam>, Vec<QueryParam>, Vec<BodyField>)> {
505    let mut path_params = Vec::new();
506    let mut query_params = Vec::new();
507    let mut body_fields = Vec::new();
508
509    let path_param_names = method.http_pattern.parameter_names();
510    let body_spec = method.http_rule.body.as_str();
511
512    // Build an O(1) lookup map for input fields by name.
513    let fields_by_name: HashMap<&str, &MessageField> =
514        input_fields.iter().map(|f| (f.name.as_str(), f)).collect();
515
516    let mut processed_fields = HashSet::new();
517
518    // Add path parameters in URL template order. `parameter_names()` yields normalized flat field
519    // names from the URL template (segment-binding `=pattern` suffixes already stripped by the
520    // template parser; unsupported dotted placeholders are kept out of `parameters` entirely).
521    for path_param_name in path_param_names {
522        if let Some(field) = fields_by_name.get(path_param_name.as_str()) {
523            // Path params are always included even if OUTPUT_ONLY (they appear in the URL, not
524            // the request body), but in practice OUTPUT_ONLY fields should never be path params.
525            path_params.push(PathParam {
526                name: field.name.clone(),
527                field_type: field.unified_type.clone(),
528                documentation: field.documentation.clone(),
529            });
530            processed_fields.insert(field.name.as_str());
531        } else {
532            // Unresolved placeholder: warn rather than silently misrouting the field to query/body.
533            warn!(
534                "Method {}.{}: URL path placeholder `{}` does not match any request field; \
535                 the param will be omitted from the path",
536                method.service_name, method.method_name, path_param_name
537            );
538        }
539    }
540
541    // Classify remaining fields as body or query.
542    for field in input_fields {
543        let field_name = field.name.as_str();
544
545        if processed_fields.contains(field_name) {
546            continue;
547        }
548
549        // Skip OUTPUT_ONLY fields — they are server-generated and never provided by clients.
550        if field.field_behavior.contains(&FieldBehavior::OutputOnly) {
551            processed_fields.insert(field_name);
552            continue;
553        }
554
555        // Oneof fields are always body fields and always optional.
556        if matches!(field.unified_type.base_type, BaseType::OneOf(_)) {
557            body_fields.push(BodyField {
558                name: field.name.clone(),
559                field_type: field.unified_type.clone().optional(),
560                repeated: false,
561                required: false,
562                oneof_variants: field.oneof_variants.clone(),
563                documentation: field.documentation.clone(),
564            });
565            processed_fields.insert(field_name);
566            continue;
567        }
568
569        let is_body = match body_spec {
570            "*" => true,
571            "" => false,
572            specific => specific == field_name,
573        };
574
575        if is_body {
576            let required = field.field_behavior.contains(&FieldBehavior::Required);
577            // Mark non-required singular message/oneof bodies as optional on the type so FFI
578            // bindings render them as `Option<T>` (matching their `= None` default). Required
579            // bodies keep the bare type and become required constructor params. Scalars and
580            // collections are unaffected (their optionality is already correct).
581            let needs_optional_marker = !required
582                && !field.unified_type.is_repeated
583                && matches!(
584                    field.unified_type.base_type,
585                    BaseType::Message(_) | BaseType::OneOf(_)
586                );
587            let field_type = if needs_optional_marker {
588                field.unified_type.clone().optional()
589            } else {
590                field.unified_type.clone()
591            };
592            body_fields.push(BodyField {
593                name: field.name.clone(),
594                field_type,
595                repeated: field.unified_type.is_repeated,
596                required,
597                oneof_variants: None,
598                documentation: field.documentation.clone(),
599            });
600        } else {
601            query_params.push(QueryParam {
602                name: field.name.clone(),
603                field_type: field.unified_type.clone(),
604                documentation: field.documentation.clone(),
605                resource_reference: field.resource_reference.clone(),
606            });
607        }
608        processed_fields.insert(field_name);
609    }
610
611    Ok((path_params, query_params, body_fields))
612}
613
614#[cfg(test)]
615mod tests {
616    use super::*;
617    use crate::google::api::{HttpRule, ResourceDescriptor, http_rule::Pattern};
618    use crate::parsing::types::UnifiedType;
619    use crate::parsing::{CodeGenMetadata, HttpPattern, MessageInfo, MethodMetadata, ServiceInfo};
620    use std::collections::HashMap;
621
622    fn make_metadata_with_catalog() -> CodeGenMetadata {
623        let catalog_resource = ResourceDescriptor {
624            r#type: "example.io/Catalog".to_string(),
625            pattern: vec!["catalogs/{catalog}".to_string()],
626            name_field: "name".to_string(),
627            history: 0,
628            plural: "catalogs".to_string(),
629            singular: "catalog".to_string(),
630            style: vec![],
631        };
632        let catalog_info = MessageInfo {
633            name: "Catalog".to_string(),
634            fields: vec![],
635            resource_descriptor: Some(catalog_resource),
636            documentation: None,
637        };
638        let mut messages = HashMap::new();
639        messages.insert("Catalog".to_string(), catalog_info);
640        CodeGenMetadata {
641            messages,
642            ..Default::default()
643        }
644    }
645
646    fn make_get_method() -> MethodMetadata {
647        MethodMetadata {
648            service_name: "CatalogService".to_string(),
649            method_name: "GetCatalog".to_string(),
650            input_type: "GetCatalogRequest".to_string(),
651            output_type: "Catalog".to_string(),
652            operation: None,
653            http_rule: HttpRule {
654                selector: "".to_string(),
655                pattern: Some(Pattern::Get("/catalogs/{name}".to_string())),
656                body: "".to_string(),
657                response_body: "".to_string(),
658                additional_bindings: vec![],
659            },
660            http_pattern: HttpPattern::parse("/catalogs/{name}"),
661            documentation: None,
662        }
663    }
664
665    #[test]
666    fn test_managed_resources_extraction() {
667        let metadata = make_metadata_with_catalog();
668        let service_info = ServiceInfo {
669            name: "CatalogService".to_string(),
670            package: "example.catalogs.v1".to_string(),
671            documentation: None,
672            methods: vec![make_get_method()],
673        };
674        let (service_plan, skipped) = analyze_service(&metadata, &service_info).unwrap();
675
676        assert!(skipped.is_empty());
677        assert_eq!(service_plan.managed_resources.len(), 1);
678        assert_eq!(service_plan.managed_resources[0].type_name, "Catalog");
679        assert_eq!(
680            service_plan.managed_resources[0].descriptor.r#type,
681            "example.io/Catalog"
682        );
683        assert_eq!(
684            service_plan.managed_resources[0].descriptor.singular,
685            "catalog"
686        );
687        assert_eq!(
688            service_plan.managed_resources[0].descriptor.plural,
689            "catalogs"
690        );
691    }
692
693    #[test]
694    fn test_no_duplicate_managed_resources() {
695        let metadata = make_metadata_with_catalog();
696        let update_method = MethodMetadata {
697            service_name: "CatalogService".to_string(),
698            method_name: "UpdateCatalog".to_string(),
699            input_type: "UpdateCatalogRequest".to_string(),
700            output_type: "Catalog".to_string(),
701            operation: None,
702            http_rule: HttpRule {
703                selector: "".to_string(),
704                pattern: Some(Pattern::Patch("/catalogs/{name}".to_string())),
705                body: "*".to_string(),
706                response_body: "".to_string(),
707                additional_bindings: vec![],
708            },
709            http_pattern: HttpPattern::parse("/catalogs/{name}"),
710            documentation: None,
711        };
712        let service_info = ServiceInfo {
713            name: "CatalogService".to_string(),
714            package: "example.catalogs.v1".to_string(),
715            documentation: None,
716            methods: vec![make_get_method(), update_method],
717        };
718        let (service_plan, _skipped) = analyze_service(&metadata, &service_info).unwrap();
719
720        assert_eq!(service_plan.managed_resources.len(), 1);
721        assert_eq!(service_plan.managed_resources[0].type_name, "Catalog");
722    }
723
724    #[test]
725    fn test_analyze_method_missing_http_pattern_returns_none() {
726        let metadata = CodeGenMetadata::default();
727        let method = MethodMetadata {
728            service_name: "SomeService".to_string(),
729            method_name: "SomeMethod".to_string(),
730            input_type: "".to_string(),
731            output_type: "".to_string(),
732            operation: None,
733            http_rule: HttpRule {
734                selector: "".to_string(),
735                pattern: None,
736                body: "".to_string(),
737                response_body: "".to_string(),
738                additional_bindings: vec![],
739            },
740            http_pattern: HttpPattern::parse(""),
741            documentation: None,
742        };
743        let result = analyze_method(&metadata, &method).unwrap();
744        assert!(result.is_none());
745    }
746
747    // --- extract_request_fields unit tests ---
748
749    fn make_string_field(name: &str, optional: bool) -> MessageField {
750        use crate::parsing::types::BaseType;
751        MessageField {
752            name: name.to_string(),
753            unified_type: UnifiedType {
754                base_type: BaseType::String,
755                is_optional: optional,
756                is_repeated: false,
757            },
758            documentation: None,
759            oneof_variants: None,
760            field_behavior: vec![],
761            is_sensitive: false,
762            resource_reference: None,
763        }
764    }
765
766    fn make_repeated_field(name: &str) -> MessageField {
767        use crate::parsing::types::BaseType;
768        MessageField {
769            name: name.to_string(),
770            unified_type: UnifiedType {
771                base_type: BaseType::String,
772                is_optional: false,
773                is_repeated: true,
774            },
775            documentation: None,
776            oneof_variants: None,
777            field_behavior: vec![],
778            is_sensitive: false,
779            resource_reference: None,
780        }
781    }
782
783    fn make_method_with_pattern(pattern: Pattern, body: &str, path: &str) -> MethodMetadata {
784        MethodMetadata {
785            service_name: "Svc".to_string(),
786            method_name: "Method".to_string(),
787            input_type: "".to_string(),
788            output_type: "".to_string(),
789            operation: None,
790            http_rule: HttpRule {
791                selector: "".to_string(),
792                pattern: Some(pattern),
793                body: body.to_string(),
794                response_body: "".to_string(),
795                additional_bindings: vec![],
796            },
797            http_pattern: HttpPattern::parse(path),
798            documentation: None,
799        }
800    }
801
802    #[test]
803    fn test_extract_path_params_in_url_order() {
804        let method =
805            make_method_with_pattern(Pattern::Get("/a/{x}/b/{y}".to_string()), "", "/a/{x}/b/{y}");
806        let fields = vec![make_string_field("y", false), make_string_field("x", false)];
807        let (path, query, body) = extract_request_fields(&method, &fields).unwrap();
808        // Path params should be in URL order: x, y — not field declaration order
809        assert_eq!(path.len(), 2);
810        assert_eq!(path[0].name, "x");
811        assert_eq!(path[1].name, "y");
812        assert!(query.is_empty());
813        assert!(body.is_empty());
814    }
815
816    #[test]
817    fn test_extract_body_wildcard() {
818        let method = make_method_with_pattern(Pattern::Post("/items".to_string()), "*", "/items");
819        let fields = vec![
820            make_string_field("name", false),
821            make_string_field("description", true),
822        ];
823        let (path, query, body) = extract_request_fields(&method, &fields).unwrap();
824        assert!(path.is_empty());
825        assert!(query.is_empty());
826        assert_eq!(body.len(), 2);
827    }
828
829    #[test]
830    fn test_extract_specific_body_field() {
831        let method = make_method_with_pattern(
832            Pattern::Patch("/items/{name}".to_string()),
833            "payload",
834            "/items/{name}",
835        );
836        let fields = vec![
837            make_string_field("name", false),    // path
838            make_string_field("payload", false), // body (specific)
839            make_string_field("extra", true),    // query
840        ];
841        let (path, query, body) = extract_request_fields(&method, &fields).unwrap();
842        assert_eq!(path.len(), 1);
843        assert_eq!(path[0].name, "name");
844        assert_eq!(body.len(), 1);
845        assert_eq!(body[0].name, "payload");
846        assert_eq!(query.len(), 1);
847        assert_eq!(query[0].name, "extra");
848    }
849
850    #[test]
851    fn test_extract_no_body_spec_all_query() {
852        let method = make_method_with_pattern(Pattern::Get("/items".to_string()), "", "/items");
853        let fields = vec![
854            make_string_field("filter", true),
855            make_string_field("page_size", true),
856        ];
857        let (path, query, body) = extract_request_fields(&method, &fields).unwrap();
858        assert!(path.is_empty());
859        assert_eq!(query.len(), 2);
860        assert!(body.is_empty());
861    }
862
863    #[test]
864    fn test_extract_repeated_field_becomes_body_with_repeated_flag() {
865        let method = make_method_with_pattern(Pattern::Post("/items".to_string()), "*", "/items");
866        let fields = vec![make_repeated_field("tags")];
867        let (_, _, body) = extract_request_fields(&method, &fields).unwrap();
868        assert_eq!(body.len(), 1);
869        assert!(body[0].repeated);
870    }
871
872    // ── Cross-service hierarchy chain resolution tests ─────────────────────────────────────────
873
874    /// Build a three-level metadata fixture: Catalog → Schema → Table.
875    ///
876    /// Three services:
877    /// - CatalogService: ListCatalogs (no child_type params), GetCatalog
878    /// - SchemaService: ListSchemas (catalog_name with child_type = Schema), GetSchema
879    /// - TableService: ListTables (catalog_name and schema_name both with child_type = Table), GetTable
880    fn make_three_level_metadata() -> (CodeGenMetadata, ServiceInfo, ServiceInfo, ServiceInfo) {
881        use crate::google::api::ResourceReference;
882        use crate::parsing::types::BaseType;
883
884        let mut messages = HashMap::new();
885
886        // Catalog resource
887        messages.insert(
888            "Catalog".to_string(),
889            MessageInfo {
890                name: "Catalog".to_string(),
891                fields: vec![],
892                resource_descriptor: Some(ResourceDescriptor {
893                    r#type: "example.io/Catalog".to_string(),
894                    pattern: vec!["catalogs/{catalog}".to_string()],
895                    name_field: "name".to_string(),
896                    history: 0,
897                    plural: "catalogs".to_string(),
898                    singular: "catalog".to_string(),
899                    style: vec![],
900                }),
901                documentation: None,
902            },
903        );
904
905        // Schema resource
906        messages.insert(
907            "Schema".to_string(),
908            MessageInfo {
909                name: "Schema".to_string(),
910                fields: vec![],
911                resource_descriptor: Some(ResourceDescriptor {
912                    r#type: "example.io/Schema".to_string(),
913                    pattern: vec!["schemas/{schema}".to_string()],
914                    name_field: "name".to_string(),
915                    history: 0,
916                    plural: "schemas".to_string(),
917                    singular: "schema".to_string(),
918                    style: vec![],
919                }),
920                documentation: None,
921            },
922        );
923
924        // Table resource
925        messages.insert(
926            "Table".to_string(),
927            MessageInfo {
928                name: "Table".to_string(),
929                fields: vec![],
930                resource_descriptor: Some(ResourceDescriptor {
931                    r#type: "example.io/Table".to_string(),
932                    pattern: vec!["tables/{table}".to_string()],
933                    name_field: "full_name".to_string(),
934                    history: 0,
935                    plural: "tables".to_string(),
936                    singular: "table".to_string(),
937                    style: vec![],
938                }),
939                documentation: None,
940            },
941        );
942
943        // ListCatalogsRequest — no child_type params
944        messages.insert(
945            "ListCatalogsRequest".to_string(),
946            MessageInfo {
947                name: "ListCatalogsRequest".to_string(),
948                fields: vec![],
949                resource_descriptor: None,
950                documentation: None,
951            },
952        );
953
954        // ListSchemasRequest — catalog_name with child_type = Schema
955        messages.insert(
956            "ListSchemasRequest".to_string(),
957            MessageInfo {
958                name: "ListSchemasRequest".to_string(),
959                fields: vec![MessageField {
960                    name: "catalog_name".to_string(),
961                    unified_type: UnifiedType {
962                        base_type: BaseType::String,
963                        is_optional: false,
964                        is_repeated: false,
965                    },
966                    documentation: None,
967                    oneof_variants: None,
968                    field_behavior: vec![crate::google::api::FieldBehavior::Required],
969                    is_sensitive: false,
970                    resource_reference: Some(ResourceReference {
971                        r#type: String::new(),
972                        child_type: "example.io/Schema".to_string(),
973                    }),
974                }],
975                resource_descriptor: None,
976                documentation: None,
977            },
978        );
979
980        // ListTablesRequest — catalog_name and schema_name, both child_type = Table (flat API)
981        messages.insert(
982            "ListTablesRequest".to_string(),
983            MessageInfo {
984                name: "ListTablesRequest".to_string(),
985                fields: vec![
986                    MessageField {
987                        name: "catalog_name".to_string(),
988                        unified_type: UnifiedType {
989                            base_type: BaseType::String,
990                            is_optional: false,
991                            is_repeated: false,
992                        },
993                        documentation: None,
994                        oneof_variants: None,
995                        field_behavior: vec![crate::google::api::FieldBehavior::Required],
996                        is_sensitive: false,
997                        resource_reference: Some(ResourceReference {
998                            r#type: String::new(),
999                            child_type: "example.io/Table".to_string(),
1000                        }),
1001                    },
1002                    MessageField {
1003                        name: "schema_name".to_string(),
1004                        unified_type: UnifiedType {
1005                            base_type: BaseType::String,
1006                            is_optional: false,
1007                            is_repeated: false,
1008                        },
1009                        documentation: None,
1010                        oneof_variants: None,
1011                        field_behavior: vec![crate::google::api::FieldBehavior::Required],
1012                        is_sensitive: false,
1013                        resource_reference: Some(ResourceReference {
1014                            r#type: String::new(),
1015                            child_type: "example.io/Table".to_string(),
1016                        }),
1017                    },
1018                ],
1019                resource_descriptor: None,
1020                documentation: None,
1021            },
1022        );
1023
1024        let metadata = CodeGenMetadata {
1025            messages,
1026            ..Default::default()
1027        };
1028
1029        let catalog_svc = ServiceInfo {
1030            name: "CatalogService".to_string(),
1031            package: "example.v1".to_string(),
1032            documentation: None,
1033            methods: vec![
1034                MethodMetadata {
1035                    service_name: "CatalogService".to_string(),
1036                    method_name: "ListCatalogs".to_string(),
1037                    input_type: "ListCatalogsRequest".to_string(),
1038                    output_type: "ListCatalogsResponse".to_string(),
1039                    operation: None,
1040                    http_rule: HttpRule {
1041                        selector: "".to_string(),
1042                        pattern: Some(Pattern::Get("/catalogs".to_string())),
1043                        body: "".to_string(),
1044                        response_body: "".to_string(),
1045                        additional_bindings: vec![],
1046                    },
1047                    http_pattern: HttpPattern::parse("/catalogs"),
1048                    documentation: None,
1049                },
1050                MethodMetadata {
1051                    service_name: "CatalogService".to_string(),
1052                    method_name: "GetCatalog".to_string(),
1053                    input_type: "GetCatalogRequest".to_string(),
1054                    output_type: "Catalog".to_string(),
1055                    operation: None,
1056                    http_rule: HttpRule {
1057                        selector: "".to_string(),
1058                        pattern: Some(Pattern::Get("/catalogs/{name}".to_string())),
1059                        body: "".to_string(),
1060                        response_body: "".to_string(),
1061                        additional_bindings: vec![],
1062                    },
1063                    http_pattern: HttpPattern::parse("/catalogs/{name}"),
1064                    documentation: None,
1065                },
1066            ],
1067        };
1068
1069        let schema_svc = ServiceInfo {
1070            name: "SchemaService".to_string(),
1071            package: "example.v1".to_string(),
1072            documentation: None,
1073            methods: vec![
1074                MethodMetadata {
1075                    service_name: "SchemaService".to_string(),
1076                    method_name: "ListSchemas".to_string(),
1077                    input_type: "ListSchemasRequest".to_string(),
1078                    output_type: "ListSchemasResponse".to_string(),
1079                    operation: None,
1080                    http_rule: HttpRule {
1081                        selector: "".to_string(),
1082                        pattern: Some(Pattern::Get("/schemas".to_string())),
1083                        body: "".to_string(),
1084                        response_body: "".to_string(),
1085                        additional_bindings: vec![],
1086                    },
1087                    http_pattern: HttpPattern::parse("/schemas"),
1088                    documentation: None,
1089                },
1090                MethodMetadata {
1091                    service_name: "SchemaService".to_string(),
1092                    method_name: "GetSchema".to_string(),
1093                    input_type: "GetSchemaRequest".to_string(),
1094                    output_type: "Schema".to_string(),
1095                    operation: None,
1096                    http_rule: HttpRule {
1097                        selector: "".to_string(),
1098                        pattern: Some(Pattern::Get("/schemas/{full_name}".to_string())),
1099                        body: "".to_string(),
1100                        response_body: "".to_string(),
1101                        additional_bindings: vec![],
1102                    },
1103                    http_pattern: HttpPattern::parse("/schemas/{full_name}"),
1104                    documentation: None,
1105                },
1106            ],
1107        };
1108
1109        let table_svc = ServiceInfo {
1110            name: "TableService".to_string(),
1111            package: "example.v1".to_string(),
1112            documentation: None,
1113            methods: vec![
1114                MethodMetadata {
1115                    service_name: "TableService".to_string(),
1116                    method_name: "ListTables".to_string(),
1117                    input_type: "ListTablesRequest".to_string(),
1118                    output_type: "ListTablesResponse".to_string(),
1119                    operation: None,
1120                    http_rule: HttpRule {
1121                        selector: "".to_string(),
1122                        pattern: Some(Pattern::Get("/tables".to_string())),
1123                        body: "".to_string(),
1124                        response_body: "".to_string(),
1125                        additional_bindings: vec![],
1126                    },
1127                    http_pattern: HttpPattern::parse("/tables"),
1128                    documentation: None,
1129                },
1130                MethodMetadata {
1131                    service_name: "TableService".to_string(),
1132                    method_name: "GetTable".to_string(),
1133                    input_type: "GetTableRequest".to_string(),
1134                    output_type: "Table".to_string(),
1135                    operation: None,
1136                    http_rule: HttpRule {
1137                        selector: "".to_string(),
1138                        pattern: Some(Pattern::Get("/tables/{full_name}".to_string())),
1139                        body: "".to_string(),
1140                        response_body: "".to_string(),
1141                        additional_bindings: vec![],
1142                    },
1143                    http_pattern: HttpPattern::parse("/tables/{full_name}"),
1144                    documentation: None,
1145                },
1146            ],
1147        };
1148
1149        (metadata, catalog_svc, schema_svc, table_svc)
1150    }
1151
1152    /// Build service plans from three-level fixture metadata for hierarchy testing.
1153    fn make_plans_from_fixture() -> (Vec<ServicePlan>, CodeGenMetadata) {
1154        let (metadata, catalog_svc, schema_svc, table_svc) = make_three_level_metadata();
1155        let mut plans = Vec::new();
1156        for svc in &[&catalog_svc, &schema_svc, &table_svc] {
1157            let (plan, _) = analyze_service(&metadata, svc).unwrap();
1158            plans.push(plan);
1159        }
1160        (plans, metadata)
1161    }
1162
1163    #[test]
1164    fn test_build_global_parent_map() {
1165        let (plans, metadata) = make_plans_from_fixture();
1166        let map = build_global_parent_map(&plans, &metadata);
1167
1168        // Catalog → Schema (from ListSchemasRequest.catalog_name with child_type=Schema)
1169        assert_eq!(
1170            map.get(&(
1171                "example.io/Catalog".to_string(),
1172                "example.io/Schema".to_string()
1173            )),
1174            Some(&"catalog_name".to_string()),
1175            "Catalog→Schema mapping missing"
1176        );
1177        // Schema → Table (from ListTablesRequest.schema_name with child_type=Table)
1178        assert_eq!(
1179            map.get(&(
1180                "example.io/Schema".to_string(),
1181                "example.io/Table".to_string()
1182            )),
1183            Some(&"schema_name".to_string()),
1184            "Schema→Table mapping missing"
1185        );
1186        // Catalog → Table (flat-API artifact from ListTablesRequest.catalog_name)
1187        assert_eq!(
1188            map.get(&(
1189                "example.io/Catalog".to_string(),
1190                "example.io/Table".to_string()
1191            )),
1192            Some(&"catalog_name".to_string()),
1193            "Catalog→Table flat-API mapping missing"
1194        );
1195    }
1196
1197    #[test]
1198    fn test_derive_ordered_hierarchy_three_levels() {
1199        let (plans, metadata) = make_plans_from_fixture();
1200        let map = build_global_parent_map(&plans, &metadata);
1201        let table_plan = plans
1202            .iter()
1203            .find(|p| p.service_name == "TableService")
1204            .unwrap();
1205        let hierarchy = derive_ordered_hierarchy(table_plan, &map, &metadata)
1206            .expect("no cycles in test fixture");
1207
1208        assert_eq!(hierarchy.len(), 2, "expected 2 ancestors for Table");
1209        // Root-first: catalog_name (depth 0) before schema_name (depth 1)
1210        assert_eq!(hierarchy[0].parent_field_name, "catalog_name");
1211        assert_eq!(hierarchy[1].parent_field_name, "schema_name");
1212        assert_eq!(hierarchy[0].parent_resource_type, "example.io/Catalog");
1213        assert_eq!(hierarchy[1].parent_resource_type, "example.io/Schema");
1214        assert_eq!(hierarchy[0].parent_singular, Some("catalog".to_string()));
1215        assert_eq!(hierarchy[1].parent_singular, Some("schema".to_string()));
1216        // child_resource_type is the managed resource (Table) for all entries
1217        assert!(
1218            hierarchy
1219                .iter()
1220                .all(|h| h.child_resource_type == "example.io/Table")
1221        );
1222    }
1223
1224    #[test]
1225    fn test_derive_ordered_hierarchy_two_levels() {
1226        let (plans, metadata) = make_plans_from_fixture();
1227        let map = build_global_parent_map(&plans, &metadata);
1228        let schema_plan = plans
1229            .iter()
1230            .find(|p| p.service_name == "SchemaService")
1231            .unwrap();
1232        let hierarchy = derive_ordered_hierarchy(schema_plan, &map, &metadata)
1233            .expect("no cycles in test fixture");
1234
1235        assert_eq!(hierarchy.len(), 1);
1236        assert_eq!(hierarchy[0].parent_field_name, "catalog_name");
1237        assert_eq!(hierarchy[0].parent_resource_type, "example.io/Catalog");
1238    }
1239
1240    #[test]
1241    fn test_derive_ordered_hierarchy_root_resource() {
1242        let (plans, metadata) = make_plans_from_fixture();
1243        let map = build_global_parent_map(&plans, &metadata);
1244        let catalog_plan = plans
1245            .iter()
1246            .find(|p| p.service_name == "CatalogService")
1247            .unwrap();
1248        let hierarchy = derive_ordered_hierarchy(catalog_plan, &map, &metadata)
1249            .expect("no cycles in test fixture");
1250
1251        assert!(
1252            hierarchy.is_empty(),
1253            "root resource should have empty hierarchy"
1254        );
1255    }
1256
1257    #[test]
1258    fn test_compute_depth_cycle_guard() {
1259        // A ↔ B mutual cycle: compute_depth should return Err, not panic or recurse infinitely.
1260        let mut map: GlobalParentMap = HashMap::new();
1261        map.insert(("A".to_string(), "B".to_string()), "a_name".to_string());
1262        map.insert(("B".to_string(), "A".to_string()), "b_name".to_string());
1263
1264        assert!(compute_depth("A", &map, &mut HashSet::new()).is_err());
1265        assert!(compute_depth("B", &map, &mut HashSet::new()).is_err());
1266    }
1267
1268    #[test]
1269    fn test_full_analyze_metadata_hierarchy_ordering() {
1270        let (metadata, catalog_svc, schema_svc, table_svc) = make_three_level_metadata();
1271        let mut services_map = HashMap::new();
1272        services_map.insert("CatalogService".to_string(), catalog_svc);
1273        services_map.insert("SchemaService".to_string(), schema_svc);
1274        services_map.insert("TableService".to_string(), table_svc);
1275        let full_metadata = CodeGenMetadata {
1276            messages: metadata.messages,
1277            services: services_map,
1278            ..Default::default()
1279        };
1280
1281        let plan = analyze_metadata(&full_metadata).unwrap();
1282        let table_svc_plan = plan
1283            .services
1284            .iter()
1285            .find(|s| s.service_name == "TableService")
1286            .expect("TableService plan not found");
1287
1288        assert_eq!(table_svc_plan.hierarchy.len(), 2);
1289        assert_eq!(
1290            table_svc_plan.hierarchy[0].parent_field_name,
1291            "catalog_name"
1292        );
1293        assert_eq!(table_svc_plan.hierarchy[1].parent_field_name, "schema_name");
1294    }
1295
1296    #[test]
1297    fn resource_accessor_params_nested_and_flat() {
1298        let (metadata, catalog_svc, schema_svc, table_svc) = make_three_level_metadata();
1299        let mut services_map = HashMap::new();
1300        services_map.insert("CatalogService".to_string(), catalog_svc);
1301        services_map.insert("SchemaService".to_string(), schema_svc);
1302        services_map.insert("TableService".to_string(), table_svc);
1303        let full_metadata = CodeGenMetadata {
1304            messages: metadata.messages,
1305            services: services_map,
1306            ..Default::default()
1307        };
1308
1309        let plan = analyze_metadata(&full_metadata).unwrap();
1310        let params = |name: &str| {
1311            plan.services
1312                .iter()
1313                .find(|s| s.service_name == name)
1314                .unwrap()
1315                .resource_accessor_params
1316                .clone()
1317        };
1318
1319        // Nested resource (annotation-driven): ancestors root-first + own leaf.
1320        assert_eq!(
1321            params("TableService"),
1322            Some(vec![
1323                "catalog_name".to_string(),
1324                "schema_name".to_string(),
1325                "table_name".to_string(),
1326            ])
1327        );
1328        // One ancestor.
1329        assert_eq!(
1330            params("SchemaService"),
1331            Some(vec!["catalog_name".to_string(), "schema_name".to_string()])
1332        );
1333        // Flat top-level resource: just its own name.
1334        assert_eq!(
1335            params("CatalogService"),
1336            Some(vec!["catalog_name".to_string()])
1337        );
1338    }
1339
1340    #[test]
1341    fn direct_children_via_accessor_param_prefix() {
1342        let (metadata, catalog_svc, schema_svc, table_svc) = make_three_level_metadata();
1343        let mut services_map = HashMap::new();
1344        services_map.insert("CatalogService".to_string(), catalog_svc);
1345        services_map.insert("SchemaService".to_string(), schema_svc);
1346        services_map.insert("TableService".to_string(), table_svc);
1347        let full_metadata = CodeGenMetadata {
1348            messages: metadata.messages,
1349            services: services_map,
1350            ..Default::default()
1351        };
1352
1353        let plan = analyze_metadata(&full_metadata).unwrap();
1354        let children = |name: &str| -> Vec<String> {
1355            plan.services
1356                .iter()
1357                .find(|s| s.service_name == name)
1358                .unwrap()
1359                .direct_children
1360                .iter()
1361                .map(|c| c.child_singular.clone())
1362                .collect()
1363        };
1364
1365        // Catalog → Schema (params extend by one); Catalog is NOT a direct parent of Table (extends
1366        // by two), so Table must not appear under Catalog.
1367        assert_eq!(children("CatalogService"), vec!["schema".to_string()]);
1368        // Schema → Table.
1369        assert_eq!(children("SchemaService"), vec!["table".to_string()]);
1370        // Table is a leaf: no children.
1371        assert!(children("TableService").is_empty());
1372
1373        // The child link carries enough to construct the child clients.
1374        let catalog = plan
1375            .services
1376            .iter()
1377            .find(|s| s.service_name == "CatalogService")
1378            .unwrap();
1379        let schema_link = &catalog.direct_children[0];
1380        // base_path comes from the child service name (here `SchemaService` → `schema`).
1381        let schema_base_path = plan
1382            .services
1383            .iter()
1384            .find(|s| s.service_name == "SchemaService")
1385            .unwrap()
1386            .base_path
1387            .clone();
1388        assert_eq!(schema_link.child_base_path, schema_base_path);
1389        assert_eq!(
1390            schema_link.child_accessor_params,
1391            vec!["catalog_name".to_string(), "schema_name".to_string()]
1392        );
1393    }
1394
1395    // The pagination-leak regression (a flat resource's List `page_token`/`max_results` no longer
1396    // bleed into its accessor params) is covered end-to-end by the golden snapshot test
1397    // (`tests/golden_integration.rs`), whose `example.bin` ListCatalogs request carries those very
1398    // fields. `is_standard_list_field` is unit-tested below.
1399
1400    #[test]
1401    fn standard_list_fields_recognized() {
1402        for f in [
1403            "page_token",
1404            "page_size",
1405            "max_results",
1406            "order_by",
1407            "filter",
1408        ] {
1409            assert!(
1410                is_standard_list_field(f),
1411                "{f} should be a standard list field"
1412            );
1413        }
1414        for f in ["name", "catalog_name", "schema_name"] {
1415            assert!(
1416                !is_standard_list_field(f),
1417                "{f} should NOT be a standard list field"
1418            );
1419        }
1420    }
1421}