Skip to main content

olai_codegen/
utils.rs

1//! Shared utilities for the proto-gen crate
2//!
3//! This module contains common functions used across different parts of the code generation
4//! pipeline to reduce duplication and improve maintainability.
5use convert_case::{Case, Casing};
6
7/// Extract the last dot-separated segment of a fully-qualified protobuf type name.
8///
9/// For example, `".unitycatalog.catalog.v1.Catalog"` returns `"Catalog"`.
10/// If the name contains no dots, the original string is returned unchanged.
11pub fn extract_simple_type_name(name: &str) -> String {
12    name.split('.').next_back().unwrap_or(name).to_string()
13}
14
15/// Derive the **emitted** type name for a (possibly nested) protobuf type from its fully-qualified
16/// name, disambiguating nested types by their parent message.
17///
18/// Package and version segments are lowercase; type segments are PascalCase. This joins the trailing
19/// run of type segments (everything from the first PascalCase segment onward), so a top-level type is
20/// unchanged while a nested type is prefixed by its enclosing message(s):
21///
22/// - `".pkg.v1.Catalog"` → `"Catalog"`
23/// - `".pkg.v1.GenerateTemporaryTableCredentialsRequest.Operation"` →
24///   `"GenerateTemporaryTableCredentialsRequestOperation"`
25///
26/// This keeps emitted names collision-free when the same nested name (e.g. `Operation`) appears in
27/// multiple messages, and must be used consistently for both type *definitions* and *references*.
28pub fn extract_qualified_type_name(name: &str) -> String {
29    let segments: Vec<&str> = name.split('.').filter(|s| !s.is_empty()).collect();
30    // A type segment starts with an ASCII uppercase letter (PascalCase); package/version segments
31    // do not. Find the first such segment and join from there to the end.
32    let start = segments
33        .iter()
34        .position(|s| s.starts_with(|c: char| c.is_ascii_uppercase()));
35    match start {
36        Some(i) => segments[i..].concat(),
37        // No PascalCase segment (shouldn't happen for a real type) — fall back to the last segment.
38        None => extract_simple_type_name(name),
39    }
40}
41
42/// String manipulation utilities
43pub mod strings {
44    use super::*;
45
46    /// Convert service name to handler trait name
47    /// e.g., "CatalogsService" -> "CatalogHandler", "TagPoliciesService" -> "TagPolicyHandler"
48    pub fn service_to_handler_name(service_name: &str) -> String {
49        if let Some(base) = service_name.strip_suffix("Service") {
50            format!("{}Handler", singularize(base))
51        } else {
52            format!("{}Handler", service_name)
53        }
54    }
55
56    /// Naive singularization of a PascalCase plural noun.
57    ///
58    /// Handles the common `-ies -> -y` rule (e.g. `TagPolicies` -> `TagPolicy`) before
59    /// falling back to trimming a trailing `s` (e.g. `Catalogs` -> `Catalog`).
60    fn singularize(word: &str) -> String {
61        if let Some(stem) = word.strip_suffix("ies") {
62            format!("{stem}y")
63        } else {
64            word.trim_end_matches('s').to_string()
65        }
66    }
67
68    /// Convert operation ID to handler method name
69    /// e.g., "ListCatalogs" -> "list_catalogs"
70    pub fn operation_to_method_name(operation_id: &str) -> String {
71        operation_id.to_case(Case::Snake)
72    }
73
74    /// Extract base path from service name
75    /// e.g., "CatalogsService" -> "catalogs"
76    pub fn service_to_base_path(service_name: &str) -> String {
77        if let Some(base) = service_name.strip_suffix("Service") {
78            base.to_case(Case::Snake)
79        } else {
80            service_name.to_case(Case::Snake)
81        }
82    }
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88
89    mod string_tests {
90        use super::*;
91
92        #[test]
93        fn test_service_to_handler_name() {
94            assert_eq!(
95                strings::service_to_handler_name("CatalogsService"),
96                "CatalogHandler"
97            );
98            assert_eq!(
99                strings::service_to_handler_name("RecipientsService"),
100                "RecipientHandler"
101            );
102            assert_eq!(
103                strings::service_to_handler_name("SchemasService"),
104                "SchemaHandler"
105            );
106            assert_eq!(
107                strings::service_to_handler_name("TagPoliciesService"),
108                "TagPolicyHandler"
109            );
110        }
111
112        #[test]
113        fn test_operation_to_method_name() {
114            assert_eq!(
115                strings::operation_to_method_name("ListCatalogs"),
116                "list_catalogs"
117            );
118            assert_eq!(
119                strings::operation_to_method_name("CreateCatalog"),
120                "create_catalog"
121            );
122            assert_eq!(
123                strings::operation_to_method_name("GetCatalog"),
124                "get_catalog"
125            );
126        }
127
128        #[test]
129        fn test_service_to_base_path() {
130            assert_eq!(strings::service_to_base_path("CatalogsService"), "catalogs");
131            assert_eq!(
132                strings::service_to_base_path("RecipientsService"),
133                "recipients"
134            );
135        }
136    }
137
138    mod qualified_type_name {
139        use super::*;
140
141        #[test]
142        fn top_level_type_is_unchanged() {
143            assert_eq!(
144                extract_qualified_type_name(".unitycatalog.catalog.v1.Catalog"),
145                "Catalog"
146            );
147            assert_eq!(extract_qualified_type_name(".pkg.v1.UpdateX"), "UpdateX");
148        }
149
150        #[test]
151        fn nested_type_is_prefixed_by_parent() {
152            assert_eq!(
153                extract_qualified_type_name(
154                    ".unitycatalog.temporary_credentials.v1.GenerateTemporaryTableCredentialsRequest.Operation"
155                ),
156                "GenerateTemporaryTableCredentialsRequestOperation"
157            );
158        }
159
160        #[test]
161        fn distinct_parents_yield_distinct_names() {
162            let a = extract_qualified_type_name(".pkg.v1.GenerateTableCredsRequest.Operation");
163            let b = extract_qualified_type_name(".pkg.v1.GenerateVolumeCredsRequest.Operation");
164            assert_ne!(a, b);
165        }
166
167        #[test]
168        fn no_leading_dot_or_package() {
169            // A bare PascalCase name is returned as-is.
170            assert_eq!(extract_qualified_type_name("Catalog"), "Catalog");
171        }
172    }
173}