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, `".example.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    last_segment(name).to_string()
13}
14
15/// The last dot-separated segment of `name` (all of `name` when it has no dot).
16pub(crate) fn last_segment(name: &str) -> &str {
17    name.rsplit('.').next().unwrap_or(name)
18}
19
20/// Derive the **emitted** type name for a (possibly nested) protobuf type from its fully-qualified
21/// name, disambiguating nested types by their parent message.
22///
23/// Package and version segments are lowercase; type segments are PascalCase. This joins the trailing
24/// run of type segments (everything from the first PascalCase segment onward), so a top-level type is
25/// unchanged while a nested type is prefixed by its enclosing message(s):
26///
27/// - `".pkg.v1.Catalog"` → `"Catalog"`
28/// - `".pkg.v1.GenerateTemporaryTableCredentialsRequest.Operation"` →
29///   `"GenerateTemporaryTableCredentialsRequestOperation"`
30///
31/// This keeps emitted names collision-free when the same nested name (e.g. `Operation`) appears in
32/// multiple messages, and must be used consistently for both type *definitions* and *references*.
33pub fn extract_qualified_type_name(name: &str) -> String {
34    let segments: Vec<&str> = name.split('.').filter(|s| !s.is_empty()).collect();
35    // A type segment starts with an ASCII uppercase letter (PascalCase); package/version segments
36    // do not. Find the first such segment and join from there to the end.
37    let start = segments
38        .iter()
39        .position(|s| s.starts_with(|c: char| c.is_ascii_uppercase()));
40    match start {
41        Some(i) => segments[i..].concat(),
42        // No PascalCase segment (shouldn't happen for a real type) — fall back to the last segment.
43        None => extract_simple_type_name(name),
44    }
45}
46
47/// `name` with the leading `.` that the [`CodeGenMetadata`](crate::parsing::CodeGenMetadata)
48/// tables are keyed by (field types carry it without).
49pub(crate) fn metadata_key(name: &str) -> String {
50    if name.starts_with('.') {
51        name.to_string()
52    } else {
53        format!(".{name}")
54    }
55}
56
57/// String manipulation utilities
58pub mod strings {
59    use super::*;
60
61    /// The proto3 JSON name of a proto field name: `storage_root` → `storageRoot`.
62    pub fn lower_camel(name: &str) -> String {
63        let mut out = String::with_capacity(name.len());
64        let mut upper_next = false;
65        for ch in name.chars() {
66            if ch == '_' {
67                upper_next = true;
68            } else if upper_next {
69                out.push(ch.to_ascii_uppercase());
70                upper_next = false;
71            } else {
72                out.push(ch);
73            }
74        }
75        out
76    }
77
78    /// Convert service name to handler trait name
79    /// e.g., "CatalogsService" -> "CatalogHandler", "TagPoliciesService" -> "TagPolicyHandler"
80    pub fn service_to_handler_name(service_name: &str) -> String {
81        if let Some(base) = service_name.strip_suffix("Service") {
82            format!("{}Handler", singularize(base))
83        } else {
84            format!("{service_name}Handler")
85        }
86    }
87
88    /// Naive singularization of a PascalCase plural noun.
89    ///
90    /// Handles the common `-ies -> -y` rule (e.g. `TagPolicies` -> `TagPolicy`) before
91    /// falling back to trimming a trailing `s` (e.g. `Catalogs` -> `Catalog`).
92    fn singularize(word: &str) -> String {
93        if let Some(stem) = word.strip_suffix("ies") {
94            format!("{stem}y")
95        } else {
96            word.trim_end_matches('s').to_string()
97        }
98    }
99
100    /// Convert operation ID to handler method name
101    /// e.g., "ListCatalogs" -> "list_catalogs"
102    pub fn operation_to_method_name(operation_id: &str) -> String {
103        operation_id.to_case(Case::Snake)
104    }
105
106    /// Extract base path from service name
107    /// e.g., "CatalogsService" -> "catalogs"
108    pub fn service_to_base_path(service_name: &str) -> String {
109        if let Some(base) = service_name.strip_suffix("Service") {
110            base.to_case(Case::Snake)
111        } else {
112            service_name.to_case(Case::Snake)
113        }
114    }
115}
116
117/// The Rust identifier of a proto field: raw (`r#type`) for a keyword, as buffa and prost name
118/// such fields.
119pub(crate) fn field_ident(name: &str) -> proc_macro2::Ident {
120    const KEYWORDS: &[&str] = &[
121        "abstract", "as", "async", "await", "become", "box", "break", "const", "continue", "do",
122        "dyn", "else", "enum", "extern", "false", "final", "fn", "for", "gen", "if", "impl", "in",
123        "let", "loop", "macro", "match", "mod", "move", "mut", "override", "priv", "pub", "ref",
124        "return", "static", "struct", "trait", "true", "try", "type", "typeof", "unsafe",
125        "unsized", "use", "virtual", "where", "while", "yield",
126    ];
127    if KEYWORDS.contains(&name) {
128        proc_macro2::Ident::new_raw(name, proc_macro2::Span::call_site())
129    } else {
130        proc_macro2::Ident::new(name, proc_macro2::Span::call_site())
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    mod string_tests {
139        use super::*;
140
141        #[test]
142        fn test_service_to_handler_name() {
143            assert_eq!(
144                strings::service_to_handler_name("CatalogsService"),
145                "CatalogHandler"
146            );
147            assert_eq!(
148                strings::service_to_handler_name("RecipientsService"),
149                "RecipientHandler"
150            );
151            assert_eq!(
152                strings::service_to_handler_name("SchemasService"),
153                "SchemaHandler"
154            );
155            assert_eq!(
156                strings::service_to_handler_name("TagPoliciesService"),
157                "TagPolicyHandler"
158            );
159        }
160
161        #[test]
162        fn test_operation_to_method_name() {
163            assert_eq!(
164                strings::operation_to_method_name("ListCatalogs"),
165                "list_catalogs"
166            );
167            assert_eq!(
168                strings::operation_to_method_name("CreateCatalog"),
169                "create_catalog"
170            );
171            assert_eq!(
172                strings::operation_to_method_name("GetCatalog"),
173                "get_catalog"
174            );
175        }
176
177        #[test]
178        fn test_service_to_base_path() {
179            assert_eq!(strings::service_to_base_path("CatalogsService"), "catalogs");
180            assert_eq!(
181                strings::service_to_base_path("RecipientsService"),
182                "recipients"
183            );
184        }
185    }
186
187    mod qualified_type_name {
188        use super::*;
189
190        #[test]
191        fn top_level_type_is_unchanged() {
192            assert_eq!(
193                extract_qualified_type_name(".unitycatalog.catalog.v1.Catalog"),
194                "Catalog"
195            );
196            assert_eq!(extract_qualified_type_name(".pkg.v1.UpdateX"), "UpdateX");
197        }
198
199        #[test]
200        fn nested_type_is_prefixed_by_parent() {
201            assert_eq!(
202                extract_qualified_type_name(
203                    ".unitycatalog.temporary_credentials.v1.GenerateTemporaryTableCredentialsRequest.Operation"
204                ),
205                "GenerateTemporaryTableCredentialsRequestOperation"
206            );
207        }
208
209        #[test]
210        fn distinct_parents_yield_distinct_names() {
211            let a = extract_qualified_type_name(".pkg.v1.GenerateTableCredsRequest.Operation");
212            let b = extract_qualified_type_name(".pkg.v1.GenerateVolumeCredsRequest.Operation");
213            assert_ne!(a, b);
214        }
215
216        #[test]
217        fn no_leading_dot_or_package() {
218            // A bare PascalCase name is returned as-is.
219            assert_eq!(extract_qualified_type_name("Catalog"), "Catalog");
220        }
221    }
222}