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, GenerationPlan, ManagedResource, MethodPlan, PathParam, QueryParam, RequestParam,
53    RequestType, ResourceHierarchy, ServicePlan, SkippedMethod, extract_managed_resources,
54    split_body_fields,
55};
56
57mod types;
58
59/// Analyze collected metadata and create a generation plan.
60///
61/// Methods with missing HTTP annotations are excluded from the plan and recorded in
62/// [`GenerationPlan::skipped_methods`] so callers can distinguish "zero methods generated"
63/// from "all methods were silently dropped".
64///
65/// Hierarchy derivation uses a two-phase cross-service algorithm:
66/// 1. Build all service plans (hierarchy empty).
67/// 2. Construct a global parent map across all services, then assign depth-sorted hierarchies.
68pub fn analyze_metadata(metadata: &CodeGenMetadata) -> Result<GenerationPlan> {
69    // Phase 1: analyze all services without hierarchy.
70    let mut plans = Vec::new();
71    let mut skipped_methods = Vec::new();
72    for service_info in metadata.services.values() {
73        let (plan, skipped) = analyze_service(metadata, service_info)?;
74        plans.push(plan);
75        skipped_methods.extend(skipped);
76    }
77
78    // Phase 2: build the cross-service global parent map, then assign depth-sorted hierarchies.
79    let global_map = build_global_parent_map(&plans, metadata);
80    let services = plans
81        .into_iter()
82        .map(|mut plan| {
83            plan.hierarchy = derive_ordered_hierarchy(&plan, &global_map, metadata)?;
84            Ok(plan)
85        })
86        .collect::<Result<Vec<_>>>()?;
87
88    Ok(GenerationPlan {
89        services,
90        skipped_methods,
91    })
92}
93
94// ── Cross-service hierarchy resolution ────────────────────────────────────────────────────────
95
96/// Maps `(parent_resource_type, child_resource_type)` → proto field name that carries the
97/// parent identifier on the child service's List request (e.g. `"catalog_name"`).
98type GlobalParentMap = HashMap<(String, String), String>;
99
100/// Build a map of immediate-parent relationships by scanning every service's List method query
101/// params for `child_type`-annotated fields.
102///
103/// **Key semantic filter:** only params where `child_type == this service's managed resource type`
104/// are recorded. This ensures that `catalog_name` on `ListTablesRequest` (child_type = Table) is
105/// recorded as `(Catalog, Table)`, and `schema_name` as `(Schema, Table)` — but when walking
106/// Schema's own List method, `catalog_name` (child_type = Schema) is recorded as `(Catalog, Schema)`.
107/// The chain `Catalog → Schema → Table` is then reconstructable via depth analysis.
108fn build_global_parent_map(plans: &[ServicePlan], metadata: &CodeGenMetadata) -> GlobalParentMap {
109    let mut map: GlobalParentMap = HashMap::new();
110
111    for plan in plans {
112        let managed_type = match plan.managed_resources.first() {
113            Some(r) => &r.descriptor.r#type,
114            None => continue,
115        };
116        if managed_type.is_empty() {
117            continue;
118        }
119
120        for method in &plan.methods {
121            if method.request_type != RequestType::List {
122                continue;
123            }
124            for param in method.query_parameters() {
125                let Some(ref rr) = param.resource_reference else {
126                    continue;
127                };
128                // Only record when child_type matches this service's own managed resource type.
129                if rr.child_type != *managed_type {
130                    continue;
131                }
132                if let Some(parent_type) = resolve_parent_type_from_field(&param.name, metadata) {
133                    map.entry((parent_type, managed_type.clone()))
134                        .or_insert_with(|| param.name.clone());
135                }
136            }
137        }
138    }
139
140    map
141}
142
143/// Derive the ordered ancestor chain for a service's managed resource using the global parent map.
144///
145/// Returns entries sorted **root-first** (shallowest ancestor first), so iterating them gives
146/// the correct left-to-right param order (e.g. `[catalog_name, schema_name]` for a Table service).
147///
148/// Returns `Err` if a cycle is detected in the resource hierarchy annotations.
149fn derive_ordered_hierarchy(
150    plan: &ServicePlan,
151    global_map: &GlobalParentMap,
152    metadata: &CodeGenMetadata,
153) -> Result<Vec<ResourceHierarchy>> {
154    let managed_type = match plan.managed_resources.first() {
155        Some(r) => r.descriptor.r#type.clone(),
156        None => return Ok(vec![]),
157    };
158
159    // Collect all (parent_resource_type, parent_field_name) entries for this managed resource.
160    let mut ancestors: Vec<(usize, String, String)> = global_map
161        .iter()
162        .filter(|((_, child), _)| child == &managed_type)
163        .map(|((parent_type, _), field_name)| {
164            let depth = compute_depth(parent_type, global_map, &mut HashSet::new())?;
165            Ok((depth, parent_type.clone(), field_name.clone()))
166        })
167        .collect::<Result<Vec<_>>>()?;
168
169    if ancestors.is_empty() {
170        return Ok(vec![]);
171    }
172
173    // Sort root-first (ascending depth).
174    ancestors.sort_by_key(|(depth, _, _)| *depth);
175
176    Ok(ancestors
177        .into_iter()
178        .map(|(_, parent_type, field_name)| {
179            let parent_singular = field_name
180                .strip_suffix("_name")
181                .and_then(|s| metadata.resource_from_singular(s))
182                .map(|_| field_name.strip_suffix("_name").unwrap().to_string());
183            ResourceHierarchy {
184                child_resource_type: managed_type.clone(),
185                parent_resource_type: parent_type,
186                parent_field_name: field_name,
187                parent_singular,
188            }
189        })
190        .collect())
191}
192
193/// Compute the depth of a resource type in the parent chain (0 = root, no parent in map).
194///
195/// Returns `Err` if a cycle is detected in the resource hierarchy annotations.
196fn compute_depth(
197    resource_type: &str,
198    map: &GlobalParentMap,
199    visited: &mut HashSet<String>,
200) -> Result<usize> {
201    if !visited.insert(resource_type.to_string()) {
202        return Err(crate::Error::Build(format!(
203            "Cycle detected in resource hierarchy at type: {resource_type}"
204        )));
205    }
206    let parent = map
207        .iter()
208        .find(|((_, child), _)| child == resource_type)
209        .map(|((parent, _), _)| parent.clone());
210
211    match parent {
212        None => Ok(0),
213        Some(parent_type) => Ok(1 + compute_depth(&parent_type, map, visited)?),
214    }
215}
216
217/// Resolve the resource type string for a proto field name by stripping `"_name"` and looking up
218/// the resulting singular in `metadata`.
219///
220/// Returns `None` if the field name doesn't end in `"_name"` or no resource matches.
221fn resolve_parent_type_from_field(field_name: &str, metadata: &CodeGenMetadata) -> Option<String> {
222    field_name
223        .strip_suffix("_name")
224        .and_then(|s| metadata.resource_from_singular(s))
225        .map(|rd| rd.r#type.clone())
226}
227
228/// Analyze a single service and create a service plan.
229///
230/// Returns the plan and a list of methods that were skipped due to incomplete metadata.
231fn analyze_service(
232    metadata: &CodeGenMetadata,
233    info: &ServiceInfo,
234) -> Result<(ServicePlan, Vec<SkippedMethod>)> {
235    let handler_name = strings::service_to_handler_name(&info.name);
236    let base_path = strings::service_to_base_path(&info.name);
237
238    let mut method_plans = Vec::new();
239    let mut skipped = Vec::new();
240
241    for method in &info.methods {
242        if let Some(method_plan) = analyze_method(metadata, method)? {
243            method_plans.push(method_plan);
244        } else {
245            warn!(
246                "Skipping method {}.{} - incomplete metadata",
247                info.name, method.method_name
248            );
249            skipped.push(SkippedMethod {
250                service_name: info.name.clone(),
251                method_name: method.method_name.clone(),
252                reason: "missing HTTP annotation".to_string(),
253            });
254        }
255    }
256
257    let managed_resources = types::extract_managed_resources(metadata, &method_plans);
258
259    Ok((
260        ServicePlan {
261            service_name: info.name.clone(),
262            handler_name,
263            base_path,
264            package: info.package.clone(),
265            methods: method_plans,
266            managed_resources,
267            documentation: info.documentation.clone(),
268            hierarchy: vec![], // filled in Phase 2 of analyze_metadata
269        },
270        skipped,
271    ))
272}
273
274/// Analyze a single method and create a method plan.
275///
276/// Returns `None` if the method has incomplete metadata (e.g., missing HTTP annotation).
277pub(crate) fn analyze_method(
278    metadata: &CodeGenMetadata,
279    method: &MethodMetadata,
280) -> Result<Option<MethodPlan>> {
281    let http_method = match method.http_method() {
282        Some(m) => m.to_string(),
283        None => {
284            warn!(
285                "Method {}.{} missing HTTP info",
286                method.service_name, method.method_name
287            );
288            return Ok(None);
289        }
290    };
291
292    let planner = MethodPlanner::try_new(method, metadata)?;
293    let request_type = planner.request_type();
294    let has_response = planner.has_response();
295    let output_resource_type = planner.output_resource_type();
296    let http_pattern = planner.into_http_pattern();
297
298    let input_fields = metadata.get_message_fields(&method.input_type);
299    let (path_params, query_params, body_fields) = extract_request_fields(method, &input_fields)?;
300
301    let parameters = path_params
302        .into_iter()
303        .map(Into::into)
304        .chain(query_params.into_iter().map(Into::into))
305        .chain(body_fields.into_iter().map(Into::into))
306        .collect();
307
308    Ok(Some(MethodPlan {
309        metadata: method.clone(),
310        handler_function_name: method.method_name.to_case(Case::Snake),
311        http_method,
312        parameters,
313        has_response,
314        request_type,
315        output_resource_type,
316        http_pattern,
317    }))
318}
319
320/// Extract and classify request fields from an input message into path, query, and body buckets.
321///
322/// - Path parameters are matched against URL template parameters and ordered accordingly.
323/// - Fields annotated `OUTPUT_ONLY` are excluded entirely — they are server-generated and
324///   must not appear in request extractors or client request builders.
325/// - Fields matching the `body` spec (`"*"`, `""`, or a specific field name) become body fields.
326/// - All remaining fields become query parameters. Fields not explicitly marked optional
327///   via `UnifiedType.is_optional` are treated as required query parameters.
328/// - Oneof fields are always placed in the body as optional variants.
329fn extract_request_fields(
330    method: &MethodMetadata,
331    input_fields: &[MessageField],
332) -> Result<(Vec<PathParam>, Vec<QueryParam>, Vec<BodyField>)> {
333    let mut path_params = Vec::new();
334    let mut query_params = Vec::new();
335    let mut body_fields = Vec::new();
336
337    let path_param_names = method.http_pattern.parameter_names();
338    let body_spec = method.http_rule.body.as_str();
339
340    // Build an O(1) lookup map for input fields by name.
341    let fields_by_name: HashMap<&str, &MessageField> =
342        input_fields.iter().map(|f| (f.name.as_str(), f)).collect();
343
344    let mut processed_fields = HashSet::new();
345
346    // Add path parameters in URL template order.
347    for path_param_name in path_param_names {
348        if let Some(field) = fields_by_name.get(path_param_name.as_str()) {
349            // Path params are always included even if OUTPUT_ONLY (they appear in the URL, not
350            // the request body), but in practice OUTPUT_ONLY fields should never be path params.
351            path_params.push(PathParam {
352                name: field.name.clone(),
353                field_type: field.unified_type.clone(),
354                documentation: field.documentation.clone(),
355            });
356            processed_fields.insert(field.name.as_str());
357        }
358    }
359
360    // Classify remaining fields as body or query.
361    for field in input_fields {
362        let field_name = field.name.as_str();
363
364        if processed_fields.contains(field_name) {
365            continue;
366        }
367
368        // Skip OUTPUT_ONLY fields — they are server-generated and never provided by clients.
369        if field.field_behavior.contains(&FieldBehavior::OutputOnly) {
370            processed_fields.insert(field_name);
371            continue;
372        }
373
374        // Oneof fields are always body fields and always optional.
375        if matches!(field.unified_type.base_type, BaseType::OneOf(_)) {
376            body_fields.push(BodyField {
377                name: field.name.clone(),
378                field_type: field.unified_type.clone().optional(),
379                repeated: false,
380                oneof_variants: field.oneof_variants.clone(),
381                documentation: field.documentation.clone(),
382            });
383            processed_fields.insert(field_name);
384            continue;
385        }
386
387        let is_body = match body_spec {
388            "*" => true,
389            "" => false,
390            specific => specific == field_name,
391        };
392
393        if is_body {
394            body_fields.push(BodyField {
395                name: field.name.clone(),
396                field_type: field.unified_type.clone(),
397                repeated: field.unified_type.is_repeated,
398                oneof_variants: None,
399                documentation: field.documentation.clone(),
400            });
401        } else {
402            query_params.push(QueryParam {
403                name: field.name.clone(),
404                field_type: field.unified_type.clone(),
405                documentation: field.documentation.clone(),
406                resource_reference: field.resource_reference.clone(),
407            });
408        }
409        processed_fields.insert(field_name);
410    }
411
412    Ok((path_params, query_params, body_fields))
413}
414
415#[cfg(test)]
416mod tests {
417    use super::*;
418    use crate::google::api::{HttpRule, ResourceDescriptor, http_rule::Pattern};
419    use crate::parsing::types::UnifiedType;
420    use crate::parsing::{CodeGenMetadata, HttpPattern, MessageInfo, MethodMetadata, ServiceInfo};
421    use std::collections::HashMap;
422
423    fn make_metadata_with_catalog() -> CodeGenMetadata {
424        let catalog_resource = ResourceDescriptor {
425            r#type: "example.io/Catalog".to_string(),
426            pattern: vec!["catalogs/{catalog}".to_string()],
427            name_field: "name".to_string(),
428            history: 0,
429            plural: "catalogs".to_string(),
430            singular: "catalog".to_string(),
431            style: vec![],
432        };
433        let catalog_info = MessageInfo {
434            name: "Catalog".to_string(),
435            fields: vec![],
436            resource_descriptor: Some(catalog_resource),
437            documentation: None,
438        };
439        let mut messages = HashMap::new();
440        messages.insert("Catalog".to_string(), catalog_info);
441        CodeGenMetadata {
442            messages,
443            ..Default::default()
444        }
445    }
446
447    fn make_get_method() -> MethodMetadata {
448        MethodMetadata {
449            service_name: "CatalogService".to_string(),
450            method_name: "GetCatalog".to_string(),
451            input_type: "GetCatalogRequest".to_string(),
452            output_type: "Catalog".to_string(),
453            operation: None,
454            http_rule: HttpRule {
455                selector: "".to_string(),
456                pattern: Some(Pattern::Get("/catalogs/{name}".to_string())),
457                body: "".to_string(),
458                response_body: "".to_string(),
459                additional_bindings: vec![],
460            },
461            http_pattern: HttpPattern::parse("/catalogs/{name}"),
462            documentation: None,
463        }
464    }
465
466    #[test]
467    fn test_managed_resources_extraction() {
468        let metadata = make_metadata_with_catalog();
469        let service_info = ServiceInfo {
470            name: "CatalogService".to_string(),
471            package: "example.catalogs.v1".to_string(),
472            documentation: None,
473            methods: vec![make_get_method()],
474        };
475        let (service_plan, skipped) = analyze_service(&metadata, &service_info).unwrap();
476
477        assert!(skipped.is_empty());
478        assert_eq!(service_plan.managed_resources.len(), 1);
479        assert_eq!(service_plan.managed_resources[0].type_name, "Catalog");
480        assert_eq!(
481            service_plan.managed_resources[0].descriptor.r#type,
482            "example.io/Catalog"
483        );
484        assert_eq!(
485            service_plan.managed_resources[0].descriptor.singular,
486            "catalog"
487        );
488        assert_eq!(
489            service_plan.managed_resources[0].descriptor.plural,
490            "catalogs"
491        );
492    }
493
494    #[test]
495    fn test_no_duplicate_managed_resources() {
496        let metadata = make_metadata_with_catalog();
497        let update_method = MethodMetadata {
498            service_name: "CatalogService".to_string(),
499            method_name: "UpdateCatalog".to_string(),
500            input_type: "UpdateCatalogRequest".to_string(),
501            output_type: "Catalog".to_string(),
502            operation: None,
503            http_rule: HttpRule {
504                selector: "".to_string(),
505                pattern: Some(Pattern::Patch("/catalogs/{name}".to_string())),
506                body: "*".to_string(),
507                response_body: "".to_string(),
508                additional_bindings: vec![],
509            },
510            http_pattern: HttpPattern::parse("/catalogs/{name}"),
511            documentation: None,
512        };
513        let service_info = ServiceInfo {
514            name: "CatalogService".to_string(),
515            package: "example.catalogs.v1".to_string(),
516            documentation: None,
517            methods: vec![make_get_method(), update_method],
518        };
519        let (service_plan, _skipped) = analyze_service(&metadata, &service_info).unwrap();
520
521        assert_eq!(service_plan.managed_resources.len(), 1);
522        assert_eq!(service_plan.managed_resources[0].type_name, "Catalog");
523    }
524
525    #[test]
526    fn test_analyze_method_missing_http_pattern_returns_none() {
527        let metadata = CodeGenMetadata::default();
528        let method = MethodMetadata {
529            service_name: "SomeService".to_string(),
530            method_name: "SomeMethod".to_string(),
531            input_type: "".to_string(),
532            output_type: "".to_string(),
533            operation: None,
534            http_rule: HttpRule {
535                selector: "".to_string(),
536                pattern: None,
537                body: "".to_string(),
538                response_body: "".to_string(),
539                additional_bindings: vec![],
540            },
541            http_pattern: HttpPattern::parse(""),
542            documentation: None,
543        };
544        let result = analyze_method(&metadata, &method).unwrap();
545        assert!(result.is_none());
546    }
547
548    // --- extract_request_fields unit tests ---
549
550    fn make_string_field(name: &str, optional: bool) -> MessageField {
551        use crate::parsing::types::BaseType;
552        MessageField {
553            name: name.to_string(),
554            unified_type: UnifiedType {
555                base_type: BaseType::String,
556                is_optional: optional,
557                is_repeated: false,
558            },
559            documentation: None,
560            oneof_variants: None,
561            field_behavior: vec![],
562            is_sensitive: false,
563            resource_reference: None,
564        }
565    }
566
567    fn make_repeated_field(name: &str) -> MessageField {
568        use crate::parsing::types::BaseType;
569        MessageField {
570            name: name.to_string(),
571            unified_type: UnifiedType {
572                base_type: BaseType::String,
573                is_optional: false,
574                is_repeated: true,
575            },
576            documentation: None,
577            oneof_variants: None,
578            field_behavior: vec![],
579            is_sensitive: false,
580            resource_reference: None,
581        }
582    }
583
584    fn make_method_with_pattern(pattern: Pattern, body: &str, path: &str) -> MethodMetadata {
585        MethodMetadata {
586            service_name: "Svc".to_string(),
587            method_name: "Method".to_string(),
588            input_type: "".to_string(),
589            output_type: "".to_string(),
590            operation: None,
591            http_rule: HttpRule {
592                selector: "".to_string(),
593                pattern: Some(pattern),
594                body: body.to_string(),
595                response_body: "".to_string(),
596                additional_bindings: vec![],
597            },
598            http_pattern: HttpPattern::parse(path),
599            documentation: None,
600        }
601    }
602
603    #[test]
604    fn test_extract_path_params_in_url_order() {
605        let method =
606            make_method_with_pattern(Pattern::Get("/a/{x}/b/{y}".to_string()), "", "/a/{x}/b/{y}");
607        let fields = vec![make_string_field("y", false), make_string_field("x", false)];
608        let (path, query, body) = extract_request_fields(&method, &fields).unwrap();
609        // Path params should be in URL order: x, y — not field declaration order
610        assert_eq!(path.len(), 2);
611        assert_eq!(path[0].name, "x");
612        assert_eq!(path[1].name, "y");
613        assert!(query.is_empty());
614        assert!(body.is_empty());
615    }
616
617    #[test]
618    fn test_extract_body_wildcard() {
619        let method = make_method_with_pattern(Pattern::Post("/items".to_string()), "*", "/items");
620        let fields = vec![
621            make_string_field("name", false),
622            make_string_field("description", true),
623        ];
624        let (path, query, body) = extract_request_fields(&method, &fields).unwrap();
625        assert!(path.is_empty());
626        assert!(query.is_empty());
627        assert_eq!(body.len(), 2);
628    }
629
630    #[test]
631    fn test_extract_specific_body_field() {
632        let method = make_method_with_pattern(
633            Pattern::Patch("/items/{name}".to_string()),
634            "payload",
635            "/items/{name}",
636        );
637        let fields = vec![
638            make_string_field("name", false),    // path
639            make_string_field("payload", false), // body (specific)
640            make_string_field("extra", true),    // query
641        ];
642        let (path, query, body) = extract_request_fields(&method, &fields).unwrap();
643        assert_eq!(path.len(), 1);
644        assert_eq!(path[0].name, "name");
645        assert_eq!(body.len(), 1);
646        assert_eq!(body[0].name, "payload");
647        assert_eq!(query.len(), 1);
648        assert_eq!(query[0].name, "extra");
649    }
650
651    #[test]
652    fn test_extract_no_body_spec_all_query() {
653        let method = make_method_with_pattern(Pattern::Get("/items".to_string()), "", "/items");
654        let fields = vec![
655            make_string_field("filter", true),
656            make_string_field("page_size", true),
657        ];
658        let (path, query, body) = extract_request_fields(&method, &fields).unwrap();
659        assert!(path.is_empty());
660        assert_eq!(query.len(), 2);
661        assert!(body.is_empty());
662    }
663
664    #[test]
665    fn test_extract_repeated_field_becomes_body_with_repeated_flag() {
666        let method = make_method_with_pattern(Pattern::Post("/items".to_string()), "*", "/items");
667        let fields = vec![make_repeated_field("tags")];
668        let (_, _, body) = extract_request_fields(&method, &fields).unwrap();
669        assert_eq!(body.len(), 1);
670        assert!(body[0].repeated);
671    }
672
673    // ── Cross-service hierarchy chain resolution tests ─────────────────────────────────────────
674
675    /// Build a three-level metadata fixture: Catalog → Schema → Table.
676    ///
677    /// Three services:
678    /// - CatalogService: ListCatalogs (no child_type params), GetCatalog
679    /// - SchemaService: ListSchemas (catalog_name with child_type = Schema), GetSchema
680    /// - TableService: ListTables (catalog_name and schema_name both with child_type = Table), GetTable
681    fn make_three_level_metadata() -> (CodeGenMetadata, ServiceInfo, ServiceInfo, ServiceInfo) {
682        use crate::google::api::ResourceReference;
683        use crate::parsing::types::BaseType;
684
685        let mut messages = HashMap::new();
686
687        // Catalog resource
688        messages.insert(
689            "Catalog".to_string(),
690            MessageInfo {
691                name: "Catalog".to_string(),
692                fields: vec![],
693                resource_descriptor: Some(ResourceDescriptor {
694                    r#type: "example.io/Catalog".to_string(),
695                    pattern: vec!["catalogs/{catalog}".to_string()],
696                    name_field: "name".to_string(),
697                    history: 0,
698                    plural: "catalogs".to_string(),
699                    singular: "catalog".to_string(),
700                    style: vec![],
701                }),
702                documentation: None,
703            },
704        );
705
706        // Schema resource
707        messages.insert(
708            "Schema".to_string(),
709            MessageInfo {
710                name: "Schema".to_string(),
711                fields: vec![],
712                resource_descriptor: Some(ResourceDescriptor {
713                    r#type: "example.io/Schema".to_string(),
714                    pattern: vec!["schemas/{schema}".to_string()],
715                    name_field: "name".to_string(),
716                    history: 0,
717                    plural: "schemas".to_string(),
718                    singular: "schema".to_string(),
719                    style: vec![],
720                }),
721                documentation: None,
722            },
723        );
724
725        // Table resource
726        messages.insert(
727            "Table".to_string(),
728            MessageInfo {
729                name: "Table".to_string(),
730                fields: vec![],
731                resource_descriptor: Some(ResourceDescriptor {
732                    r#type: "example.io/Table".to_string(),
733                    pattern: vec!["tables/{table}".to_string()],
734                    name_field: "full_name".to_string(),
735                    history: 0,
736                    plural: "tables".to_string(),
737                    singular: "table".to_string(),
738                    style: vec![],
739                }),
740                documentation: None,
741            },
742        );
743
744        // ListCatalogsRequest — no child_type params
745        messages.insert(
746            "ListCatalogsRequest".to_string(),
747            MessageInfo {
748                name: "ListCatalogsRequest".to_string(),
749                fields: vec![],
750                resource_descriptor: None,
751                documentation: None,
752            },
753        );
754
755        // ListSchemasRequest — catalog_name with child_type = Schema
756        messages.insert(
757            "ListSchemasRequest".to_string(),
758            MessageInfo {
759                name: "ListSchemasRequest".to_string(),
760                fields: vec![MessageField {
761                    name: "catalog_name".to_string(),
762                    unified_type: UnifiedType {
763                        base_type: BaseType::String,
764                        is_optional: false,
765                        is_repeated: false,
766                    },
767                    documentation: None,
768                    oneof_variants: None,
769                    field_behavior: vec![crate::google::api::FieldBehavior::Required],
770                    is_sensitive: false,
771                    resource_reference: Some(ResourceReference {
772                        r#type: String::new(),
773                        child_type: "example.io/Schema".to_string(),
774                    }),
775                }],
776                resource_descriptor: None,
777                documentation: None,
778            },
779        );
780
781        // ListTablesRequest — catalog_name and schema_name, both child_type = Table (flat API)
782        messages.insert(
783            "ListTablesRequest".to_string(),
784            MessageInfo {
785                name: "ListTablesRequest".to_string(),
786                fields: vec![
787                    MessageField {
788                        name: "catalog_name".to_string(),
789                        unified_type: UnifiedType {
790                            base_type: BaseType::String,
791                            is_optional: false,
792                            is_repeated: false,
793                        },
794                        documentation: None,
795                        oneof_variants: None,
796                        field_behavior: vec![crate::google::api::FieldBehavior::Required],
797                        is_sensitive: false,
798                        resource_reference: Some(ResourceReference {
799                            r#type: String::new(),
800                            child_type: "example.io/Table".to_string(),
801                        }),
802                    },
803                    MessageField {
804                        name: "schema_name".to_string(),
805                        unified_type: UnifiedType {
806                            base_type: BaseType::String,
807                            is_optional: false,
808                            is_repeated: false,
809                        },
810                        documentation: None,
811                        oneof_variants: None,
812                        field_behavior: vec![crate::google::api::FieldBehavior::Required],
813                        is_sensitive: false,
814                        resource_reference: Some(ResourceReference {
815                            r#type: String::new(),
816                            child_type: "example.io/Table".to_string(),
817                        }),
818                    },
819                ],
820                resource_descriptor: None,
821                documentation: None,
822            },
823        );
824
825        let metadata = CodeGenMetadata {
826            messages,
827            ..Default::default()
828        };
829
830        let catalog_svc = ServiceInfo {
831            name: "CatalogService".to_string(),
832            package: "example.v1".to_string(),
833            documentation: None,
834            methods: vec![
835                MethodMetadata {
836                    service_name: "CatalogService".to_string(),
837                    method_name: "ListCatalogs".to_string(),
838                    input_type: "ListCatalogsRequest".to_string(),
839                    output_type: "ListCatalogsResponse".to_string(),
840                    operation: None,
841                    http_rule: HttpRule {
842                        selector: "".to_string(),
843                        pattern: Some(Pattern::Get("/catalogs".to_string())),
844                        body: "".to_string(),
845                        response_body: "".to_string(),
846                        additional_bindings: vec![],
847                    },
848                    http_pattern: HttpPattern::parse("/catalogs"),
849                    documentation: None,
850                },
851                MethodMetadata {
852                    service_name: "CatalogService".to_string(),
853                    method_name: "GetCatalog".to_string(),
854                    input_type: "GetCatalogRequest".to_string(),
855                    output_type: "Catalog".to_string(),
856                    operation: None,
857                    http_rule: HttpRule {
858                        selector: "".to_string(),
859                        pattern: Some(Pattern::Get("/catalogs/{name}".to_string())),
860                        body: "".to_string(),
861                        response_body: "".to_string(),
862                        additional_bindings: vec![],
863                    },
864                    http_pattern: HttpPattern::parse("/catalogs/{name}"),
865                    documentation: None,
866                },
867            ],
868        };
869
870        let schema_svc = ServiceInfo {
871            name: "SchemaService".to_string(),
872            package: "example.v1".to_string(),
873            documentation: None,
874            methods: vec![
875                MethodMetadata {
876                    service_name: "SchemaService".to_string(),
877                    method_name: "ListSchemas".to_string(),
878                    input_type: "ListSchemasRequest".to_string(),
879                    output_type: "ListSchemasResponse".to_string(),
880                    operation: None,
881                    http_rule: HttpRule {
882                        selector: "".to_string(),
883                        pattern: Some(Pattern::Get("/schemas".to_string())),
884                        body: "".to_string(),
885                        response_body: "".to_string(),
886                        additional_bindings: vec![],
887                    },
888                    http_pattern: HttpPattern::parse("/schemas"),
889                    documentation: None,
890                },
891                MethodMetadata {
892                    service_name: "SchemaService".to_string(),
893                    method_name: "GetSchema".to_string(),
894                    input_type: "GetSchemaRequest".to_string(),
895                    output_type: "Schema".to_string(),
896                    operation: None,
897                    http_rule: HttpRule {
898                        selector: "".to_string(),
899                        pattern: Some(Pattern::Get("/schemas/{full_name}".to_string())),
900                        body: "".to_string(),
901                        response_body: "".to_string(),
902                        additional_bindings: vec![],
903                    },
904                    http_pattern: HttpPattern::parse("/schemas/{full_name}"),
905                    documentation: None,
906                },
907            ],
908        };
909
910        let table_svc = ServiceInfo {
911            name: "TableService".to_string(),
912            package: "example.v1".to_string(),
913            documentation: None,
914            methods: vec![
915                MethodMetadata {
916                    service_name: "TableService".to_string(),
917                    method_name: "ListTables".to_string(),
918                    input_type: "ListTablesRequest".to_string(),
919                    output_type: "ListTablesResponse".to_string(),
920                    operation: None,
921                    http_rule: HttpRule {
922                        selector: "".to_string(),
923                        pattern: Some(Pattern::Get("/tables".to_string())),
924                        body: "".to_string(),
925                        response_body: "".to_string(),
926                        additional_bindings: vec![],
927                    },
928                    http_pattern: HttpPattern::parse("/tables"),
929                    documentation: None,
930                },
931                MethodMetadata {
932                    service_name: "TableService".to_string(),
933                    method_name: "GetTable".to_string(),
934                    input_type: "GetTableRequest".to_string(),
935                    output_type: "Table".to_string(),
936                    operation: None,
937                    http_rule: HttpRule {
938                        selector: "".to_string(),
939                        pattern: Some(Pattern::Get("/tables/{full_name}".to_string())),
940                        body: "".to_string(),
941                        response_body: "".to_string(),
942                        additional_bindings: vec![],
943                    },
944                    http_pattern: HttpPattern::parse("/tables/{full_name}"),
945                    documentation: None,
946                },
947            ],
948        };
949
950        (metadata, catalog_svc, schema_svc, table_svc)
951    }
952
953    /// Build service plans from three-level fixture metadata for hierarchy testing.
954    fn make_plans_from_fixture() -> (Vec<ServicePlan>, CodeGenMetadata) {
955        let (metadata, catalog_svc, schema_svc, table_svc) = make_three_level_metadata();
956        let mut plans = Vec::new();
957        for svc in &[&catalog_svc, &schema_svc, &table_svc] {
958            let (plan, _) = analyze_service(&metadata, svc).unwrap();
959            plans.push(plan);
960        }
961        (plans, metadata)
962    }
963
964    #[test]
965    fn test_build_global_parent_map() {
966        let (plans, metadata) = make_plans_from_fixture();
967        let map = build_global_parent_map(&plans, &metadata);
968
969        // Catalog → Schema (from ListSchemasRequest.catalog_name with child_type=Schema)
970        assert_eq!(
971            map.get(&(
972                "example.io/Catalog".to_string(),
973                "example.io/Schema".to_string()
974            )),
975            Some(&"catalog_name".to_string()),
976            "Catalog→Schema mapping missing"
977        );
978        // Schema → Table (from ListTablesRequest.schema_name with child_type=Table)
979        assert_eq!(
980            map.get(&(
981                "example.io/Schema".to_string(),
982                "example.io/Table".to_string()
983            )),
984            Some(&"schema_name".to_string()),
985            "Schema→Table mapping missing"
986        );
987        // Catalog → Table (flat-API artifact from ListTablesRequest.catalog_name)
988        assert_eq!(
989            map.get(&(
990                "example.io/Catalog".to_string(),
991                "example.io/Table".to_string()
992            )),
993            Some(&"catalog_name".to_string()),
994            "Catalog→Table flat-API mapping missing"
995        );
996    }
997
998    #[test]
999    fn test_derive_ordered_hierarchy_three_levels() {
1000        let (plans, metadata) = make_plans_from_fixture();
1001        let map = build_global_parent_map(&plans, &metadata);
1002        let table_plan = plans
1003            .iter()
1004            .find(|p| p.service_name == "TableService")
1005            .unwrap();
1006        let hierarchy = derive_ordered_hierarchy(table_plan, &map, &metadata)
1007            .expect("no cycles in test fixture");
1008
1009        assert_eq!(hierarchy.len(), 2, "expected 2 ancestors for Table");
1010        // Root-first: catalog_name (depth 0) before schema_name (depth 1)
1011        assert_eq!(hierarchy[0].parent_field_name, "catalog_name");
1012        assert_eq!(hierarchy[1].parent_field_name, "schema_name");
1013        assert_eq!(hierarchy[0].parent_resource_type, "example.io/Catalog");
1014        assert_eq!(hierarchy[1].parent_resource_type, "example.io/Schema");
1015        assert_eq!(hierarchy[0].parent_singular, Some("catalog".to_string()));
1016        assert_eq!(hierarchy[1].parent_singular, Some("schema".to_string()));
1017        // child_resource_type is the managed resource (Table) for all entries
1018        assert!(
1019            hierarchy
1020                .iter()
1021                .all(|h| h.child_resource_type == "example.io/Table")
1022        );
1023    }
1024
1025    #[test]
1026    fn test_derive_ordered_hierarchy_two_levels() {
1027        let (plans, metadata) = make_plans_from_fixture();
1028        let map = build_global_parent_map(&plans, &metadata);
1029        let schema_plan = plans
1030            .iter()
1031            .find(|p| p.service_name == "SchemaService")
1032            .unwrap();
1033        let hierarchy = derive_ordered_hierarchy(schema_plan, &map, &metadata)
1034            .expect("no cycles in test fixture");
1035
1036        assert_eq!(hierarchy.len(), 1);
1037        assert_eq!(hierarchy[0].parent_field_name, "catalog_name");
1038        assert_eq!(hierarchy[0].parent_resource_type, "example.io/Catalog");
1039    }
1040
1041    #[test]
1042    fn test_derive_ordered_hierarchy_root_resource() {
1043        let (plans, metadata) = make_plans_from_fixture();
1044        let map = build_global_parent_map(&plans, &metadata);
1045        let catalog_plan = plans
1046            .iter()
1047            .find(|p| p.service_name == "CatalogService")
1048            .unwrap();
1049        let hierarchy = derive_ordered_hierarchy(catalog_plan, &map, &metadata)
1050            .expect("no cycles in test fixture");
1051
1052        assert!(
1053            hierarchy.is_empty(),
1054            "root resource should have empty hierarchy"
1055        );
1056    }
1057
1058    #[test]
1059    fn test_compute_depth_cycle_guard() {
1060        // A ↔ B mutual cycle: compute_depth should return Err, not panic or recurse infinitely.
1061        let mut map: GlobalParentMap = HashMap::new();
1062        map.insert(("A".to_string(), "B".to_string()), "a_name".to_string());
1063        map.insert(("B".to_string(), "A".to_string()), "b_name".to_string());
1064
1065        assert!(compute_depth("A", &map, &mut HashSet::new()).is_err());
1066        assert!(compute_depth("B", &map, &mut HashSet::new()).is_err());
1067    }
1068
1069    #[test]
1070    fn test_full_analyze_metadata_hierarchy_ordering() {
1071        let (metadata, catalog_svc, schema_svc, table_svc) = make_three_level_metadata();
1072        let mut services_map = HashMap::new();
1073        services_map.insert("CatalogService".to_string(), catalog_svc);
1074        services_map.insert("SchemaService".to_string(), schema_svc);
1075        services_map.insert("TableService".to_string(), table_svc);
1076        let full_metadata = CodeGenMetadata {
1077            messages: metadata.messages,
1078            services: services_map,
1079            ..Default::default()
1080        };
1081
1082        let plan = analyze_metadata(&full_metadata).unwrap();
1083        let table_svc_plan = plan
1084            .services
1085            .iter()
1086            .find(|s| s.service_name == "TableService")
1087            .expect("TableService plan not found");
1088
1089        assert_eq!(table_svc_plan.hierarchy.len(), 2);
1090        assert_eq!(
1091            table_svc_plan.hierarchy[0].parent_field_name,
1092            "catalog_name"
1093        );
1094        assert_eq!(table_svc_plan.hierarchy[1].parent_field_name, "schema_name");
1095    }
1096}