Skip to main content

olai_codegen/
utils.rs

1//! Shared utilities for the olai-codegen 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/// `name` with the leading `.` that the [`CodeGenMetadata`](crate::parsing::CodeGenMetadata)
43/// tables are keyed by (field types carry it without).
44pub(crate) fn metadata_key(name: &str) -> String {
45    if name.starts_with('.') {
46        name.to_string()
47    } else {
48        format!(".{name}")
49    }
50}
51
52/// String manipulation utilities
53pub mod strings {
54    use super::*;
55
56    /// The proto3 JSON name of a proto field name: `storage_root` → `storageRoot`.
57    pub fn lower_camel(name: &str) -> String {
58        let mut out = String::with_capacity(name.len());
59        let mut upper_next = false;
60        for ch in name.chars() {
61            if ch == '_' {
62                upper_next = true;
63            } else if upper_next {
64                out.push(ch.to_ascii_uppercase());
65                upper_next = false;
66            } else {
67                out.push(ch);
68            }
69        }
70        out
71    }
72
73    /// Convert service name to handler trait name
74    /// e.g., "CatalogsService" -> "CatalogHandler", "TagPoliciesService" -> "TagPolicyHandler"
75    pub fn service_to_handler_name(service_name: &str) -> String {
76        if let Some(base) = service_name.strip_suffix("Service") {
77            format!("{}Handler", singularize(base))
78        } else {
79            format!("{service_name}Handler")
80        }
81    }
82
83    /// Naive singularization of a PascalCase plural noun.
84    ///
85    /// Handles the common `-ies -> -y` rule (e.g. `TagPolicies` -> `TagPolicy`) before
86    /// falling back to trimming a trailing `s` (e.g. `Catalogs` -> `Catalog`).
87    fn singularize(word: &str) -> String {
88        if let Some(stem) = word.strip_suffix("ies") {
89            format!("{stem}y")
90        } else {
91            word.trim_end_matches('s').to_string()
92        }
93    }
94
95    /// Convert operation ID to handler method name
96    /// e.g., "ListCatalogs" -> "list_catalogs"
97    pub fn operation_to_method_name(operation_id: &str) -> String {
98        operation_id.to_case(Case::Snake)
99    }
100
101    /// Extract base path from service name
102    /// e.g., "CatalogsService" -> "catalogs"
103    pub fn service_to_base_path(service_name: &str) -> String {
104        if let Some(base) = service_name.strip_suffix("Service") {
105            base.to_case(Case::Snake)
106        } else {
107            service_name.to_case(Case::Snake)
108        }
109    }
110}
111
112/// The Rust identifier of a proto field: raw (`r#type`) for a keyword, as buffa and prost name
113/// such fields.
114pub(crate) fn field_ident(name: &str) -> proc_macro2::Ident {
115    const KEYWORDS: &[&str] = &[
116        "abstract", "as", "async", "await", "become", "box", "break", "const", "continue", "do",
117        "dyn", "else", "enum", "extern", "false", "final", "fn", "for", "gen", "if", "impl", "in",
118        "let", "loop", "macro", "match", "mod", "move", "mut", "override", "priv", "pub", "ref",
119        "return", "static", "struct", "trait", "true", "try", "type", "typeof", "unsafe",
120        "unsized", "use", "virtual", "where", "while", "yield",
121    ];
122    if KEYWORDS.contains(&name) {
123        proc_macro2::Ident::new_raw(name, proc_macro2::Span::call_site())
124    } else {
125        proc_macro2::Ident::new(name, proc_macro2::Span::call_site())
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    mod string_tests {
134        use super::*;
135
136        #[test]
137        fn test_service_to_handler_name() {
138            assert_eq!(
139                strings::service_to_handler_name("CatalogsService"),
140                "CatalogHandler"
141            );
142            assert_eq!(
143                strings::service_to_handler_name("RecipientsService"),
144                "RecipientHandler"
145            );
146            assert_eq!(
147                strings::service_to_handler_name("SchemasService"),
148                "SchemaHandler"
149            );
150            assert_eq!(
151                strings::service_to_handler_name("TagPoliciesService"),
152                "TagPolicyHandler"
153            );
154        }
155
156        #[test]
157        fn test_operation_to_method_name() {
158            assert_eq!(
159                strings::operation_to_method_name("ListCatalogs"),
160                "list_catalogs"
161            );
162            assert_eq!(
163                strings::operation_to_method_name("CreateCatalog"),
164                "create_catalog"
165            );
166            assert_eq!(
167                strings::operation_to_method_name("GetCatalog"),
168                "get_catalog"
169            );
170        }
171
172        #[test]
173        fn test_service_to_base_path() {
174            assert_eq!(strings::service_to_base_path("CatalogsService"), "catalogs");
175            assert_eq!(
176                strings::service_to_base_path("RecipientsService"),
177                "recipients"
178            );
179        }
180    }
181
182    mod qualified_type_name {
183        use super::*;
184
185        #[test]
186        fn top_level_type_is_unchanged() {
187            assert_eq!(
188                extract_qualified_type_name(".unitycatalog.catalog.v1.Catalog"),
189                "Catalog"
190            );
191            assert_eq!(extract_qualified_type_name(".pkg.v1.UpdateX"), "UpdateX");
192        }
193
194        #[test]
195        fn nested_type_is_prefixed_by_parent() {
196            assert_eq!(
197                extract_qualified_type_name(
198                    ".unitycatalog.temporary_credentials.v1.GenerateTemporaryTableCredentialsRequest.Operation"
199                ),
200                "GenerateTemporaryTableCredentialsRequestOperation"
201            );
202        }
203
204        #[test]
205        fn distinct_parents_yield_distinct_names() {
206            let a = extract_qualified_type_name(".pkg.v1.GenerateTableCredsRequest.Operation");
207            let b = extract_qualified_type_name(".pkg.v1.GenerateVolumeCredsRequest.Operation");
208            assert_ne!(a, b);
209        }
210
211        #[test]
212        fn no_leading_dot_or_package() {
213            // A bare PascalCase name is returned as-is.
214            assert_eq!(extract_qualified_type_name("Catalog"), "Catalog");
215        }
216    }
217}