@typokit/plugin-axum 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +81 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +186 -0
- package/dist/index.js.map +1 -0
- package/index.darwin-arm64.node +0 -0
- package/index.darwin-x64.node +0 -0
- package/index.linux-arm64-gnu.node +0 -0
- package/index.linux-x64-gnu.node +0 -0
- package/index.linux-x64-musl.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +57 -0
- package/src/index.ts +309 -0
- package/src/lib.rs +80 -0
- package/src/rust_codegen/database.rs +898 -0
- package/src/rust_codegen/handlers.rs +1111 -0
- package/src/rust_codegen/middleware.rs +156 -0
- package/src/rust_codegen/mod.rs +91 -0
- package/src/rust_codegen/project.rs +593 -0
- package/src/rust_codegen/router.rs +385 -0
- package/src/rust_codegen/services.rs +476 -0
- package/src/rust_codegen/structs.rs +1363 -0
|
@@ -0,0 +1,1363 @@
|
|
|
1
|
+
use std::collections::{HashMap, BTreeMap};
|
|
2
|
+
use typokit_transform_native::type_extractor::{TypeMetadata, PropertyMetadata};
|
|
3
|
+
use super::GeneratedOutput;
|
|
4
|
+
|
|
5
|
+
/// Generate Rust struct files from a SchemaTypeMap.
|
|
6
|
+
///
|
|
7
|
+
/// Produces one `.typokit/models/{entity}.rs` per type, a
|
|
8
|
+
/// `.typokit/models/common.rs` with shared framework types, and a
|
|
9
|
+
/// `.typokit/models/mod.rs` that re-exports all entity modules.
|
|
10
|
+
pub fn generate_structs(type_map: &HashMap<String, TypeMetadata>) -> Vec<GeneratedOutput> {
|
|
11
|
+
let mut outputs = Vec::new();
|
|
12
|
+
let mut module_names: Vec<String> = Vec::new();
|
|
13
|
+
|
|
14
|
+
// Collect utility types discovered during struct generation
|
|
15
|
+
let mut resolved_utility_types: BTreeMap<String, TypeMetadata> = BTreeMap::new();
|
|
16
|
+
|
|
17
|
+
// Sort by name for deterministic output
|
|
18
|
+
let mut sorted_types: Vec<(&String, &TypeMetadata)> = type_map.iter().collect();
|
|
19
|
+
sorted_types.sort_by_key(|(name, _)| name.to_string());
|
|
20
|
+
|
|
21
|
+
for (_name, metadata) in &sorted_types {
|
|
22
|
+
let module_name = to_snake_case(&metadata.name);
|
|
23
|
+
module_names.push(module_name.clone());
|
|
24
|
+
|
|
25
|
+
// Collect utility types referenced by properties
|
|
26
|
+
collect_utility_type_refs(metadata, type_map, &mut resolved_utility_types);
|
|
27
|
+
|
|
28
|
+
let content = generate_struct_file(metadata, type_map);
|
|
29
|
+
outputs.push(GeneratedOutput {
|
|
30
|
+
path: format!(".typokit/models/{}.rs", module_name),
|
|
31
|
+
content,
|
|
32
|
+
overwrite: true,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Generate structs for resolved utility types
|
|
37
|
+
for (name, resolved_meta) in &resolved_utility_types {
|
|
38
|
+
let module_name = to_snake_case(name);
|
|
39
|
+
if !module_names.contains(&module_name) {
|
|
40
|
+
module_names.push(module_name.clone());
|
|
41
|
+
let content = generate_struct_file(resolved_meta, type_map);
|
|
42
|
+
outputs.push(GeneratedOutput {
|
|
43
|
+
path: format!(".typokit/models/{}.rs", module_name),
|
|
44
|
+
content,
|
|
45
|
+
overwrite: true,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Generate common types (PaginatedResponse<T>, ErrorResponse)
|
|
51
|
+
module_names.push("common".to_string());
|
|
52
|
+
outputs.push(generate_common_types());
|
|
53
|
+
|
|
54
|
+
// Sort module_names for deterministic mod.rs output
|
|
55
|
+
module_names.sort();
|
|
56
|
+
|
|
57
|
+
// Collect all exported type names
|
|
58
|
+
let mut type_exports: Vec<(String, String)> = Vec::new();
|
|
59
|
+
for (_, metadata) in &sorted_types {
|
|
60
|
+
let module_name = to_snake_case(&metadata.name);
|
|
61
|
+
type_exports.push((module_name, metadata.name.clone()));
|
|
62
|
+
}
|
|
63
|
+
for (name, _) in &resolved_utility_types {
|
|
64
|
+
let module_name = to_snake_case(name);
|
|
65
|
+
type_exports.push((module_name, name.clone()));
|
|
66
|
+
}
|
|
67
|
+
type_exports.sort();
|
|
68
|
+
|
|
69
|
+
let mod_content = generate_models_mod(&module_names, &type_exports);
|
|
70
|
+
outputs.push(GeneratedOutput {
|
|
71
|
+
path: ".typokit/models/mod.rs".to_string(),
|
|
72
|
+
content: mod_content,
|
|
73
|
+
overwrite: true,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
outputs
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/// Generate a single struct file for an entity, with validation annotations
|
|
80
|
+
/// and enum types for union literals.
|
|
81
|
+
fn generate_struct_file(metadata: &TypeMetadata, type_map: &HashMap<String, TypeMetadata>) -> String {
|
|
82
|
+
let mut output = String::new();
|
|
83
|
+
|
|
84
|
+
output.push_str("// AUTO-GENERATED by @typokit/transform-native — DO NOT EDIT\n");
|
|
85
|
+
|
|
86
|
+
// Analyze what imports are needed
|
|
87
|
+
let has_chrono = metadata.properties.values().any(|p| is_date_type(&p.type_str));
|
|
88
|
+
let has_validation = metadata.properties.values().any(|p| has_any_validation(p));
|
|
89
|
+
let has_regex = metadata.properties.values().any(|p| has_pattern_annotation(p));
|
|
90
|
+
|
|
91
|
+
// Collect enums for union literal fields
|
|
92
|
+
let mut enums: Vec<(String, Vec<String>)> = Vec::new();
|
|
93
|
+
let mut sorted_props: Vec<(&String, &PropertyMetadata)> =
|
|
94
|
+
metadata.properties.iter().collect();
|
|
95
|
+
sorted_props.sort_by_key(|(name, _)| name.to_string());
|
|
96
|
+
|
|
97
|
+
for (prop_name, prop) in &sorted_props {
|
|
98
|
+
if is_union_literal(&prop.type_str) {
|
|
99
|
+
let variants = parse_union_literals(&prop.type_str);
|
|
100
|
+
let enum_name = format!("{}{}", metadata.name, to_pascal_case(&to_snake_case(prop_name)));
|
|
101
|
+
enums.push((enum_name, variants));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Imports
|
|
106
|
+
output.push_str("use serde::{Deserialize, Serialize};\n");
|
|
107
|
+
if has_validation {
|
|
108
|
+
output.push_str("use validator::Validate;\n");
|
|
109
|
+
}
|
|
110
|
+
if has_chrono {
|
|
111
|
+
output.push_str("use chrono::{DateTime, Utc};\n");
|
|
112
|
+
}
|
|
113
|
+
if has_regex {
|
|
114
|
+
output.push_str("use once_cell::sync::Lazy;\n");
|
|
115
|
+
output.push_str("use regex::Regex;\n");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
output.push('\n');
|
|
119
|
+
|
|
120
|
+
// Generate regex statics for @pattern fields
|
|
121
|
+
for (prop_name, prop) in &sorted_props {
|
|
122
|
+
if let Some(pattern) = get_pattern_annotation(prop) {
|
|
123
|
+
let static_name = regex_static_name(&metadata.name, prop_name);
|
|
124
|
+
output.push_str(&format!(
|
|
125
|
+
"static {}: Lazy<Regex> = Lazy::new(|| Regex::new(r\"{}\").unwrap());\n",
|
|
126
|
+
static_name, pattern
|
|
127
|
+
));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if has_regex {
|
|
131
|
+
output.push('\n');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Generate enum types for union literal fields
|
|
135
|
+
for (enum_name, variants) in &enums {
|
|
136
|
+
output.push_str(&generate_enum(enum_name, variants));
|
|
137
|
+
output.push('\n');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Determine derives
|
|
141
|
+
let has_table = metadata
|
|
142
|
+
.jsdoc
|
|
143
|
+
.as_ref()
|
|
144
|
+
.map(|j| j.contains_key("table"))
|
|
145
|
+
.unwrap_or(false);
|
|
146
|
+
|
|
147
|
+
let mut derives = vec!["Debug", "Clone", "Serialize", "Deserialize"];
|
|
148
|
+
if has_table {
|
|
149
|
+
derives.push("sqlx::FromRow");
|
|
150
|
+
}
|
|
151
|
+
if has_validation {
|
|
152
|
+
derives.push("Validate");
|
|
153
|
+
}
|
|
154
|
+
output.push_str(&format!("#[derive({})]\n", derives.join(", ")));
|
|
155
|
+
|
|
156
|
+
output.push_str(&format!("pub struct {} {{\n", metadata.name));
|
|
157
|
+
|
|
158
|
+
// Generate fields
|
|
159
|
+
let mut enum_idx = 0;
|
|
160
|
+
for (prop_name, prop) in &sorted_props {
|
|
161
|
+
let field_name = to_snake_case(prop_name);
|
|
162
|
+
let is_union = is_union_literal(&prop.type_str);
|
|
163
|
+
|
|
164
|
+
let rust_type = if is_union {
|
|
165
|
+
let (ref enum_name, _) = enums[enum_idx];
|
|
166
|
+
enum_idx += 1;
|
|
167
|
+
enum_name.clone()
|
|
168
|
+
} else {
|
|
169
|
+
resolve_rust_type(&prop.type_str, prop_name, &prop.jsdoc, type_map)
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// Serde rename if field name differs from original
|
|
173
|
+
if field_name != **prop_name {
|
|
174
|
+
output.push_str(&format!(" #[serde(rename = \"{}\")]\n", prop_name));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Validation attributes
|
|
178
|
+
let validation_attrs = generate_validation_attrs(prop, &metadata.name, prop_name);
|
|
179
|
+
for attr in &validation_attrs {
|
|
180
|
+
output.push_str(&format!(" {}\n", attr));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if prop.optional {
|
|
184
|
+
output.push_str(&format!(" pub {}: Option<{}>,\n", field_name, rust_type));
|
|
185
|
+
} else {
|
|
186
|
+
output.push_str(&format!(" pub {}: {},\n", field_name, rust_type));
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
output.push_str("}\n");
|
|
191
|
+
|
|
192
|
+
output
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/// Generate the models/mod.rs file that re-exports all entity modules
|
|
196
|
+
fn generate_models_mod(module_names: &[String], type_exports: &[(String, String)]) -> String {
|
|
197
|
+
let mut output = String::new();
|
|
198
|
+
output.push_str("// AUTO-GENERATED by @typokit/transform-native — DO NOT EDIT\n");
|
|
199
|
+
|
|
200
|
+
for name in module_names {
|
|
201
|
+
output.push_str(&format!("pub mod {};\n", name));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if !type_exports.is_empty() || module_names.contains(&"common".to_string()) {
|
|
205
|
+
output.push('\n');
|
|
206
|
+
for (module_name, struct_name) in type_exports {
|
|
207
|
+
output.push_str(&format!("pub use {}::{};\n", module_name, struct_name));
|
|
208
|
+
}
|
|
209
|
+
if module_names.contains(&"common".to_string()) {
|
|
210
|
+
output.push_str("pub use common::{PaginatedResponse, ErrorResponse};\n");
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
output
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/// Generate shared framework types: PaginatedResponse<T> and ErrorResponse.
|
|
218
|
+
fn generate_common_types() -> GeneratedOutput {
|
|
219
|
+
let content = r#"// AUTO-GENERATED by @typokit/transform-native — DO NOT EDIT
|
|
220
|
+
use serde::{Deserialize, Serialize};
|
|
221
|
+
|
|
222
|
+
/// Paginated response wrapper for list endpoints
|
|
223
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
224
|
+
pub struct PaginatedResponse<T> {
|
|
225
|
+
pub data: Vec<T>,
|
|
226
|
+
pub total: u32,
|
|
227
|
+
pub page: u32,
|
|
228
|
+
#[serde(rename = "pageSize")]
|
|
229
|
+
pub page_size: u32,
|
|
230
|
+
#[serde(rename = "totalPages")]
|
|
231
|
+
pub total_pages: u32,
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/// Standard error response
|
|
235
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
236
|
+
pub struct ErrorResponse {
|
|
237
|
+
pub error: String,
|
|
238
|
+
pub message: String,
|
|
239
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
240
|
+
pub details: Option<serde_json::Value>,
|
|
241
|
+
#[serde(rename = "traceId")]
|
|
242
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
243
|
+
pub trace_id: Option<String>,
|
|
244
|
+
}
|
|
245
|
+
"#;
|
|
246
|
+
|
|
247
|
+
GeneratedOutput {
|
|
248
|
+
path: ".typokit/models/common.rs".to_string(),
|
|
249
|
+
content: content.to_string(),
|
|
250
|
+
overwrite: true,
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ─────────────────────────── Validation Annotations ───────────────────────────
|
|
255
|
+
|
|
256
|
+
/// Check if a property has any validation annotations in its JSDoc
|
|
257
|
+
fn has_any_validation(prop: &PropertyMetadata) -> bool {
|
|
258
|
+
prop.jsdoc.as_ref().map_or(false, |j| {
|
|
259
|
+
j.contains_key("minLength")
|
|
260
|
+
|| j.contains_key("maxLength")
|
|
261
|
+
|| j.contains_key("format")
|
|
262
|
+
|| j.contains_key("pattern")
|
|
263
|
+
|| j.contains_key("minimum")
|
|
264
|
+
|| j.contains_key("maximum")
|
|
265
|
+
})
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/// Check if a property has a @pattern annotation
|
|
269
|
+
fn has_pattern_annotation(prop: &PropertyMetadata) -> bool {
|
|
270
|
+
prop.jsdoc
|
|
271
|
+
.as_ref()
|
|
272
|
+
.map_or(false, |j| j.contains_key("pattern"))
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/// Get the @pattern annotation value
|
|
276
|
+
fn get_pattern_annotation(prop: &PropertyMetadata) -> Option<String> {
|
|
277
|
+
prop.jsdoc
|
|
278
|
+
.as_ref()
|
|
279
|
+
.and_then(|j| j.get("pattern").cloned())
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/// Generate the name for a regex static variable
|
|
283
|
+
fn regex_static_name(struct_name: &str, field_name: &str) -> String {
|
|
284
|
+
format!(
|
|
285
|
+
"RE_{}_{}",
|
|
286
|
+
to_screaming_snake_case(struct_name),
|
|
287
|
+
to_screaming_snake_case(field_name)
|
|
288
|
+
)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/// Generate `#[validate(...)]` attributes for a property based on JSDoc tags
|
|
292
|
+
fn generate_validation_attrs(
|
|
293
|
+
prop: &PropertyMetadata,
|
|
294
|
+
struct_name: &str,
|
|
295
|
+
prop_name: &str,
|
|
296
|
+
) -> Vec<String> {
|
|
297
|
+
let mut attrs = Vec::new();
|
|
298
|
+
let jsdoc = match &prop.jsdoc {
|
|
299
|
+
Some(j) => j,
|
|
300
|
+
None => return attrs,
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
// Length validators — combine min/max into a single length() attribute
|
|
304
|
+
let min_len = jsdoc.get("minLength");
|
|
305
|
+
let max_len = jsdoc.get("maxLength");
|
|
306
|
+
if min_len.is_some() || max_len.is_some() {
|
|
307
|
+
let mut parts = Vec::new();
|
|
308
|
+
if let Some(min) = min_len {
|
|
309
|
+
parts.push(format!("min = {}", min));
|
|
310
|
+
}
|
|
311
|
+
if let Some(max) = max_len {
|
|
312
|
+
parts.push(format!("max = {}", max));
|
|
313
|
+
}
|
|
314
|
+
attrs.push(format!("#[validate(length({}))]", parts.join(", ")));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Email format
|
|
318
|
+
if jsdoc.get("format").map(|v| v.as_str()) == Some("email") {
|
|
319
|
+
attrs.push("#[validate(email)]".to_string());
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Regex pattern
|
|
323
|
+
if jsdoc.contains_key("pattern") {
|
|
324
|
+
let static_name = regex_static_name(struct_name, prop_name);
|
|
325
|
+
attrs.push(format!("#[validate(regex(path = \"*{}\"))]", static_name));
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Range validators — combine min/max into a single range() attribute
|
|
329
|
+
let minimum = jsdoc.get("minimum");
|
|
330
|
+
let maximum = jsdoc.get("maximum");
|
|
331
|
+
if minimum.is_some() || maximum.is_some() {
|
|
332
|
+
let mut parts = Vec::new();
|
|
333
|
+
if let Some(min) = minimum {
|
|
334
|
+
parts.push(format!("min = {}", min));
|
|
335
|
+
}
|
|
336
|
+
if let Some(max) = maximum {
|
|
337
|
+
parts.push(format!("max = {}", max));
|
|
338
|
+
}
|
|
339
|
+
attrs.push(format!("#[validate(range({}))]", parts.join(", ")));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
attrs
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ─────────────────────────── Union Literal Enums ──────────────────────────────
|
|
346
|
+
|
|
347
|
+
/// Check if a type string is a union of string literals
|
|
348
|
+
fn is_union_literal(ts_type: &str) -> bool {
|
|
349
|
+
if !ts_type.contains(" | ") {
|
|
350
|
+
return false;
|
|
351
|
+
}
|
|
352
|
+
ts_type
|
|
353
|
+
.split(" | ")
|
|
354
|
+
.all(|part| {
|
|
355
|
+
let trimmed = part.trim();
|
|
356
|
+
trimmed.starts_with('"') && trimmed.ends_with('"')
|
|
357
|
+
})
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/// Parse variant names from a union literal type string
|
|
361
|
+
fn parse_union_literals(ts_type: &str) -> Vec<String> {
|
|
362
|
+
ts_type
|
|
363
|
+
.split(" | ")
|
|
364
|
+
.map(|part| {
|
|
365
|
+
let trimmed = part.trim();
|
|
366
|
+
if trimmed.starts_with('"') && trimmed.ends_with('"') {
|
|
367
|
+
trimmed[1..trimmed.len() - 1].to_string()
|
|
368
|
+
} else {
|
|
369
|
+
trimmed.to_string()
|
|
370
|
+
}
|
|
371
|
+
})
|
|
372
|
+
.collect()
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/// Generate a Rust enum for a union literal type
|
|
376
|
+
fn generate_enum(name: &str, variants: &[String]) -> String {
|
|
377
|
+
let mut output = String::new();
|
|
378
|
+
output.push_str("#[derive(Debug, Clone, Serialize, Deserialize)]\n");
|
|
379
|
+
output.push_str(&format!("pub enum {} {{\n", name));
|
|
380
|
+
for variant in variants {
|
|
381
|
+
let pascal = variant_to_pascal(variant);
|
|
382
|
+
if pascal != *variant {
|
|
383
|
+
output.push_str(&format!(" #[serde(rename = \"{}\")]\n", variant));
|
|
384
|
+
}
|
|
385
|
+
output.push_str(&format!(" {},\n", pascal));
|
|
386
|
+
}
|
|
387
|
+
output.push_str("}\n");
|
|
388
|
+
output
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/// Convert a union variant string to a PascalCase enum variant name
|
|
392
|
+
fn variant_to_pascal(s: &str) -> String {
|
|
393
|
+
s.split(|c: char| c == '_' || c == '-' || c == ' ')
|
|
394
|
+
.filter(|part| !part.is_empty())
|
|
395
|
+
.map(|part| {
|
|
396
|
+
let mut chars = part.chars();
|
|
397
|
+
match chars.next() {
|
|
398
|
+
None => String::new(),
|
|
399
|
+
Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
|
|
400
|
+
}
|
|
401
|
+
})
|
|
402
|
+
.collect()
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ─────────────────────────── Utility Type Resolution ──────────────────────────
|
|
406
|
+
|
|
407
|
+
/// Collect utility type references from property type_str values.
|
|
408
|
+
///
|
|
409
|
+
/// Scans all properties in a type and resolves any Omit<T, K>, Partial<T>,
|
|
410
|
+
/// or Pick<T, K> references against the type_map.
|
|
411
|
+
fn collect_utility_type_refs(
|
|
412
|
+
metadata: &TypeMetadata,
|
|
413
|
+
type_map: &HashMap<String, TypeMetadata>,
|
|
414
|
+
resolved: &mut BTreeMap<String, TypeMetadata>,
|
|
415
|
+
) {
|
|
416
|
+
for (_prop_name, prop) in &metadata.properties {
|
|
417
|
+
// Strip array wrappers to check inner type
|
|
418
|
+
let inner_type = strip_array_wrapper(&prop.type_str);
|
|
419
|
+
if let Some((utility, base_name, args)) = parse_utility_type(inner_type) {
|
|
420
|
+
if let Some(base) = type_map.get(&base_name) {
|
|
421
|
+
let resolved_name = utility_type_name(&utility, &base_name, &args);
|
|
422
|
+
if !resolved.contains_key(&resolved_name) {
|
|
423
|
+
let resolved_meta = match utility.as_str() {
|
|
424
|
+
"Omit" => resolve_omit(base, &args, &resolved_name),
|
|
425
|
+
"Partial" => resolve_partial(base, &resolved_name),
|
|
426
|
+
"Pick" => resolve_pick(base, &args, &resolved_name),
|
|
427
|
+
_ => continue,
|
|
428
|
+
};
|
|
429
|
+
resolved.insert(resolved_name, resolved_meta);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/// Parse a utility type reference from a type string.
|
|
437
|
+
/// Returns (utility, base_type_name, field_args).
|
|
438
|
+
fn parse_utility_type(ts_type: &str) -> Option<(String, String, Vec<String>)> {
|
|
439
|
+
for prefix in &["Omit<", "Partial<", "Pick<"] {
|
|
440
|
+
if ts_type.starts_with(prefix) && ts_type.ends_with('>') {
|
|
441
|
+
let utility = prefix[..prefix.len() - 1].to_string();
|
|
442
|
+
let inner = &ts_type[prefix.len()..ts_type.len() - 1];
|
|
443
|
+
|
|
444
|
+
if utility == "Partial" {
|
|
445
|
+
return Some((utility, inner.trim().to_string(), vec![]));
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if let Some((base, args_str)) = split_generic_args(inner) {
|
|
449
|
+
let base = base.trim().to_string();
|
|
450
|
+
let args: Vec<String> = args_str
|
|
451
|
+
.split(" | ")
|
|
452
|
+
.map(|s| s.trim().trim_matches('"').to_string())
|
|
453
|
+
.collect();
|
|
454
|
+
return Some((utility, base, args));
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
None
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/// Strip array wrappers ([] or Array<>) from a type string
|
|
462
|
+
fn strip_array_wrapper(ts_type: &str) -> &str {
|
|
463
|
+
if ts_type.ends_with("[]") {
|
|
464
|
+
&ts_type[..ts_type.len() - 2]
|
|
465
|
+
} else if ts_type.starts_with("Array<") && ts_type.ends_with('>') {
|
|
466
|
+
&ts_type[6..ts_type.len() - 1]
|
|
467
|
+
} else {
|
|
468
|
+
ts_type
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/// Generate a name for a resolved utility type
|
|
473
|
+
fn utility_type_name(utility: &str, base_name: &str, args: &[String]) -> String {
|
|
474
|
+
match utility {
|
|
475
|
+
"Omit" => {
|
|
476
|
+
let fields: Vec<String> = args.iter().map(|a| to_pascal_case(&to_snake_case(a))).collect();
|
|
477
|
+
format!("{}Without{}", base_name, fields.join(""))
|
|
478
|
+
}
|
|
479
|
+
"Partial" => format!("Partial{}", base_name),
|
|
480
|
+
"Pick" => {
|
|
481
|
+
let fields: Vec<String> = args.iter().map(|a| to_pascal_case(&to_snake_case(a))).collect();
|
|
482
|
+
format!("{}Pick{}", base_name, fields.join(""))
|
|
483
|
+
}
|
|
484
|
+
_ => format!("{}{}", utility, base_name),
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/// Resolve Omit<T, K> — copy base type but remove specified fields
|
|
489
|
+
pub fn resolve_omit(base: &TypeMetadata, omit_fields: &[String], new_name: &str) -> TypeMetadata {
|
|
490
|
+
let mut properties = HashMap::new();
|
|
491
|
+
for (name, prop) in &base.properties {
|
|
492
|
+
if !omit_fields.contains(name) {
|
|
493
|
+
properties.insert(name.clone(), prop.clone());
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
TypeMetadata {
|
|
497
|
+
name: new_name.to_string(),
|
|
498
|
+
properties,
|
|
499
|
+
jsdoc: None,
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/// Resolve Partial<T> — copy base type but wrap all fields in Option
|
|
504
|
+
pub fn resolve_partial(base: &TypeMetadata, new_name: &str) -> TypeMetadata {
|
|
505
|
+
let mut properties = HashMap::new();
|
|
506
|
+
for (name, prop) in &base.properties {
|
|
507
|
+
let mut new_prop = prop.clone();
|
|
508
|
+
new_prop.optional = true;
|
|
509
|
+
properties.insert(name.clone(), new_prop);
|
|
510
|
+
}
|
|
511
|
+
TypeMetadata {
|
|
512
|
+
name: new_name.to_string(),
|
|
513
|
+
properties,
|
|
514
|
+
jsdoc: None,
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/// Resolve Pick<T, K> — copy base type keeping only specified fields
|
|
519
|
+
pub fn resolve_pick(base: &TypeMetadata, pick_fields: &[String], new_name: &str) -> TypeMetadata {
|
|
520
|
+
let mut properties = HashMap::new();
|
|
521
|
+
for (name, prop) in &base.properties {
|
|
522
|
+
if pick_fields.contains(name) {
|
|
523
|
+
properties.insert(name.clone(), prop.clone());
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
TypeMetadata {
|
|
527
|
+
name: new_name.to_string(),
|
|
528
|
+
properties,
|
|
529
|
+
jsdoc: None,
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// ─────────────────────────── Type Mapping ─────────────────────────────────────
|
|
534
|
+
|
|
535
|
+
/// Resolve a TypeScript type string to a Rust type, accounting for context
|
|
536
|
+
/// (JSDoc annotations, property name heuristics, utility type references).
|
|
537
|
+
fn resolve_rust_type(
|
|
538
|
+
ts_type: &str,
|
|
539
|
+
prop_name: &str,
|
|
540
|
+
jsdoc: &Option<HashMap<String, String>>,
|
|
541
|
+
type_map: &HashMap<String, TypeMetadata>,
|
|
542
|
+
) -> String {
|
|
543
|
+
// Check for utility type reference (e.g., Omit<User, "id">)
|
|
544
|
+
if let Some((utility, base_name, args)) = parse_utility_type(ts_type) {
|
|
545
|
+
return utility_type_name(&utility, &base_name, &args);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Array of utility type (e.g., Pick<User, "name">[] )
|
|
549
|
+
if ts_type.ends_with("[]") {
|
|
550
|
+
let inner = &ts_type[..ts_type.len() - 2];
|
|
551
|
+
if parse_utility_type(inner).is_some() {
|
|
552
|
+
let inner_type = resolve_rust_type(inner, prop_name, jsdoc, type_map);
|
|
553
|
+
return format!("Vec<{}>", inner_type);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
ts_type_to_rust_with_context(ts_type, prop_name, jsdoc)
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/// Map a TypeScript type to a Rust type with context-aware integer inference.
|
|
561
|
+
///
|
|
562
|
+
/// Context rules for number types:
|
|
563
|
+
/// - `@id` fields → `String` (UUID)
|
|
564
|
+
/// - `@integer` override → `i64`
|
|
565
|
+
/// - pagination params (page, pageSize, limit, offset, etc.) → `u32`
|
|
566
|
+
/// - general numbers → `f64`
|
|
567
|
+
fn ts_type_to_rust_with_context(
|
|
568
|
+
ts_type: &str,
|
|
569
|
+
prop_name: &str,
|
|
570
|
+
jsdoc: &Option<HashMap<String, String>>,
|
|
571
|
+
) -> String {
|
|
572
|
+
if ts_type == "number" {
|
|
573
|
+
return number_type_from_context(prop_name, jsdoc);
|
|
574
|
+
}
|
|
575
|
+
ts_type_to_rust(ts_type)
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/// Determine Rust numeric type from property context
|
|
579
|
+
fn number_type_from_context(prop_name: &str, jsdoc: &Option<HashMap<String, String>>) -> String {
|
|
580
|
+
if let Some(j) = jsdoc {
|
|
581
|
+
if j.contains_key("id") {
|
|
582
|
+
return "String".to_string();
|
|
583
|
+
}
|
|
584
|
+
if j.contains_key("integer") {
|
|
585
|
+
return "i64".to_string();
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
// Pagination heuristic based on field name
|
|
589
|
+
match prop_name {
|
|
590
|
+
"page" | "pageSize" | "page_size" | "limit" | "offset" | "perPage" | "per_page"
|
|
591
|
+
| "total" | "totalPages" | "total_pages" | "count" | "size" => "u32".to_string(),
|
|
592
|
+
_ => "f64".to_string(),
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/// Map a TypeScript type string to a Rust type string
|
|
597
|
+
fn ts_type_to_rust(ts_type: &str) -> String {
|
|
598
|
+
match ts_type {
|
|
599
|
+
"string" => "String".to_string(),
|
|
600
|
+
"number" => "f64".to_string(),
|
|
601
|
+
"boolean" => "bool".to_string(),
|
|
602
|
+
t if is_date_type(t) => "DateTime<Utc>".to_string(),
|
|
603
|
+
"any" | "unknown" => "serde_json::Value".to_string(),
|
|
604
|
+
"null" | "void" | "undefined" => "()".to_string(),
|
|
605
|
+
|
|
606
|
+
// Array types: string[] → Vec<String>
|
|
607
|
+
t if t.ends_with("[]") => {
|
|
608
|
+
let inner = &t[..t.len() - 2];
|
|
609
|
+
format!("Vec<{}>", ts_type_to_rust(inner))
|
|
610
|
+
}
|
|
611
|
+
// Array<T> → Vec<T>
|
|
612
|
+
t if t.starts_with("Array<") && t.ends_with('>') => {
|
|
613
|
+
let inner = &t[6..t.len() - 1];
|
|
614
|
+
format!("Vec<{}>", ts_type_to_rust(inner))
|
|
615
|
+
}
|
|
616
|
+
// Record<K, V> → HashMap<K, V>
|
|
617
|
+
t if t.starts_with("Record<") && t.ends_with('>') => {
|
|
618
|
+
let inner = &t[7..t.len() - 1];
|
|
619
|
+
if let Some((key, value)) = split_generic_args(inner) {
|
|
620
|
+
format!(
|
|
621
|
+
"std::collections::HashMap<{}, {}>",
|
|
622
|
+
ts_type_to_rust(key.trim()),
|
|
623
|
+
ts_type_to_rust(value.trim())
|
|
624
|
+
)
|
|
625
|
+
} else {
|
|
626
|
+
"std::collections::HashMap<String, serde_json::Value>".to_string()
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Union literal types (e.g. "active" | "inactive") → String
|
|
631
|
+
t if t.contains(" | ") => "String".to_string(),
|
|
632
|
+
|
|
633
|
+
// Named type reference (e.g. other struct) — keep as-is
|
|
634
|
+
t => t.to_string(),
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/// Check if a type string represents a Date type
|
|
639
|
+
fn is_date_type(ts_type: &str) -> bool {
|
|
640
|
+
ts_type == "Date" || ts_type == "DateTime"
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/// Split two generic type arguments separated by a comma at the top level
|
|
644
|
+
fn split_generic_args(s: &str) -> Option<(&str, &str)> {
|
|
645
|
+
let mut depth = 0;
|
|
646
|
+
for (i, c) in s.char_indices() {
|
|
647
|
+
match c {
|
|
648
|
+
'<' => depth += 1,
|
|
649
|
+
'>' => depth -= 1,
|
|
650
|
+
',' if depth == 0 => {
|
|
651
|
+
return Some((&s[..i], &s[i + 1..]));
|
|
652
|
+
}
|
|
653
|
+
_ => {}
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
None
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/// Convert a camelCase or PascalCase string to snake_case
|
|
660
|
+
fn to_snake_case(s: &str) -> String {
|
|
661
|
+
let mut result = String::new();
|
|
662
|
+
for (i, c) in s.chars().enumerate() {
|
|
663
|
+
if c.is_uppercase() {
|
|
664
|
+
if i > 0 {
|
|
665
|
+
result.push('_');
|
|
666
|
+
}
|
|
667
|
+
result.push(c.to_lowercase().next().unwrap());
|
|
668
|
+
} else {
|
|
669
|
+
result.push(c);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
result
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/// Convert a snake_case string to PascalCase
|
|
676
|
+
fn to_pascal_case(s: &str) -> String {
|
|
677
|
+
s.split('_')
|
|
678
|
+
.map(|part| {
|
|
679
|
+
let mut chars = part.chars();
|
|
680
|
+
match chars.next() {
|
|
681
|
+
None => String::new(),
|
|
682
|
+
Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
|
|
683
|
+
}
|
|
684
|
+
})
|
|
685
|
+
.collect()
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/// Convert a camelCase or PascalCase string to SCREAMING_SNAKE_CASE
|
|
689
|
+
fn to_screaming_snake_case(s: &str) -> String {
|
|
690
|
+
to_snake_case(s).to_uppercase()
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
#[cfg(test)]
|
|
694
|
+
mod tests {
|
|
695
|
+
use super::*;
|
|
696
|
+
use typokit_transform_native::type_extractor::{PropertyMetadata, TypeMetadata};
|
|
697
|
+
|
|
698
|
+
fn make_type(name: &str, props: Vec<(&str, &str, bool)>) -> TypeMetadata {
|
|
699
|
+
let mut properties = HashMap::new();
|
|
700
|
+
for (pname, ptype, optional) in props {
|
|
701
|
+
properties.insert(
|
|
702
|
+
pname.to_string(),
|
|
703
|
+
PropertyMetadata {
|
|
704
|
+
type_str: ptype.to_string(),
|
|
705
|
+
optional,
|
|
706
|
+
jsdoc: None,
|
|
707
|
+
},
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
TypeMetadata {
|
|
711
|
+
name: name.to_string(),
|
|
712
|
+
properties,
|
|
713
|
+
jsdoc: None,
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
fn make_table_type(name: &str, table_name: &str, props: Vec<(&str, &str, bool)>) -> TypeMetadata {
|
|
718
|
+
let mut metadata = make_type(name, props);
|
|
719
|
+
let mut jsdoc = HashMap::new();
|
|
720
|
+
jsdoc.insert("table".to_string(), table_name.to_string());
|
|
721
|
+
metadata.jsdoc = Some(jsdoc);
|
|
722
|
+
metadata
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
fn make_prop(type_str: &str, optional: bool) -> PropertyMetadata {
|
|
726
|
+
PropertyMetadata {
|
|
727
|
+
type_str: type_str.to_string(),
|
|
728
|
+
optional,
|
|
729
|
+
jsdoc: None,
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
fn make_prop_with_jsdoc(
|
|
734
|
+
type_str: &str,
|
|
735
|
+
optional: bool,
|
|
736
|
+
jsdoc_tags: Vec<(&str, &str)>,
|
|
737
|
+
) -> PropertyMetadata {
|
|
738
|
+
let mut jsdoc = HashMap::new();
|
|
739
|
+
for (key, value) in jsdoc_tags {
|
|
740
|
+
jsdoc.insert(key.to_string(), value.to_string());
|
|
741
|
+
}
|
|
742
|
+
PropertyMetadata {
|
|
743
|
+
type_str: type_str.to_string(),
|
|
744
|
+
optional,
|
|
745
|
+
jsdoc: Some(jsdoc),
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// ─── Original tests preserved ────────────────────────────────────────────
|
|
750
|
+
|
|
751
|
+
#[test]
|
|
752
|
+
fn test_ts_type_to_rust_primitives() {
|
|
753
|
+
assert_eq!(ts_type_to_rust("string"), "String");
|
|
754
|
+
assert_eq!(ts_type_to_rust("number"), "f64");
|
|
755
|
+
assert_eq!(ts_type_to_rust("boolean"), "bool");
|
|
756
|
+
assert_eq!(ts_type_to_rust("Date"), "DateTime<Utc>");
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
#[test]
|
|
760
|
+
fn test_ts_type_to_rust_arrays() {
|
|
761
|
+
assert_eq!(ts_type_to_rust("string[]"), "Vec<String>");
|
|
762
|
+
assert_eq!(ts_type_to_rust("number[]"), "Vec<f64>");
|
|
763
|
+
assert_eq!(ts_type_to_rust("Array<string>"), "Vec<String>");
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
#[test]
|
|
767
|
+
fn test_ts_type_to_rust_record() {
|
|
768
|
+
assert_eq!(
|
|
769
|
+
ts_type_to_rust("Record<string, unknown>"),
|
|
770
|
+
"std::collections::HashMap<String, serde_json::Value>"
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
#[test]
|
|
775
|
+
fn test_ts_type_to_rust_optional_maps_to_option() {
|
|
776
|
+
assert_eq!(ts_type_to_rust("string"), "String");
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
#[test]
|
|
780
|
+
fn test_to_snake_case() {
|
|
781
|
+
assert_eq!(to_snake_case("createdAt"), "created_at");
|
|
782
|
+
assert_eq!(to_snake_case("userId"), "user_id");
|
|
783
|
+
assert_eq!(to_snake_case("User"), "user");
|
|
784
|
+
assert_eq!(to_snake_case("HTMLParser"), "h_t_m_l_parser");
|
|
785
|
+
assert_eq!(to_snake_case("id"), "id");
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
#[test]
|
|
789
|
+
fn test_to_pascal_case() {
|
|
790
|
+
assert_eq!(to_pascal_case("user"), "User");
|
|
791
|
+
assert_eq!(to_pascal_case("created_at"), "CreatedAt");
|
|
792
|
+
assert_eq!(to_pascal_case("user_profile"), "UserProfile");
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
#[test]
|
|
796
|
+
fn test_generate_simple_struct() {
|
|
797
|
+
let mut type_map = HashMap::new();
|
|
798
|
+
type_map.insert(
|
|
799
|
+
"User".to_string(),
|
|
800
|
+
make_type("User", vec![
|
|
801
|
+
("id", "string", false),
|
|
802
|
+
("name", "string", false),
|
|
803
|
+
("age", "number", false),
|
|
804
|
+
("active", "boolean", false),
|
|
805
|
+
]),
|
|
806
|
+
);
|
|
807
|
+
|
|
808
|
+
let outputs = generate_structs(&type_map);
|
|
809
|
+
|
|
810
|
+
let user_file = outputs.iter().find(|o| o.path.contains("user.rs") && !o.path.contains("mod.rs")).unwrap();
|
|
811
|
+
assert!(user_file.content.contains("pub struct User"));
|
|
812
|
+
assert!(user_file.content.contains("pub id: String"));
|
|
813
|
+
assert!(user_file.content.contains("pub name: String"));
|
|
814
|
+
assert!(user_file.content.contains("pub active: bool"));
|
|
815
|
+
assert!(user_file.content.contains("#[derive(Debug, Clone, Serialize, Deserialize)]"));
|
|
816
|
+
assert!(!user_file.content.contains("sqlx::FromRow"));
|
|
817
|
+
assert!(user_file.overwrite);
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
#[test]
|
|
821
|
+
fn test_generate_table_struct_has_sqlx_derive() {
|
|
822
|
+
let mut type_map = HashMap::new();
|
|
823
|
+
type_map.insert(
|
|
824
|
+
"User".to_string(),
|
|
825
|
+
make_table_type("User", "users", vec![
|
|
826
|
+
("id", "string", false),
|
|
827
|
+
("name", "string", false),
|
|
828
|
+
]),
|
|
829
|
+
);
|
|
830
|
+
|
|
831
|
+
let outputs = generate_structs(&type_map);
|
|
832
|
+
let user_file = outputs.iter().find(|o| o.path.contains("user.rs") && !o.path.contains("mod.rs")).unwrap();
|
|
833
|
+
assert!(user_file.content.contains("sqlx::FromRow"));
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
#[test]
|
|
837
|
+
fn test_generate_optional_fields() {
|
|
838
|
+
let mut type_map = HashMap::new();
|
|
839
|
+
type_map.insert(
|
|
840
|
+
"Profile".to_string(),
|
|
841
|
+
make_type("Profile", vec![
|
|
842
|
+
("bio", "string", true),
|
|
843
|
+
("age", "number", true),
|
|
844
|
+
("name", "string", false),
|
|
845
|
+
]),
|
|
846
|
+
);
|
|
847
|
+
|
|
848
|
+
let outputs = generate_structs(&type_map);
|
|
849
|
+
let profile_file = outputs.iter().find(|o| o.path.contains("profile.rs")).unwrap();
|
|
850
|
+
assert!(profile_file.content.contains("pub bio: Option<String>"));
|
|
851
|
+
assert!(profile_file.content.contains("pub age: Option<f64>"));
|
|
852
|
+
assert!(profile_file.content.contains("pub name: String"));
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
#[test]
|
|
856
|
+
fn test_generate_date_fields() {
|
|
857
|
+
let mut type_map = HashMap::new();
|
|
858
|
+
type_map.insert(
|
|
859
|
+
"Event".to_string(),
|
|
860
|
+
make_type("Event", vec![
|
|
861
|
+
("id", "string", false),
|
|
862
|
+
("createdAt", "Date", false),
|
|
863
|
+
("updatedAt", "Date", true),
|
|
864
|
+
]),
|
|
865
|
+
);
|
|
866
|
+
|
|
867
|
+
let outputs = generate_structs(&type_map);
|
|
868
|
+
let event_file = outputs.iter().find(|o| o.path.contains("event.rs")).unwrap();
|
|
869
|
+
assert!(event_file.content.contains("use chrono::{DateTime, Utc};"));
|
|
870
|
+
assert!(event_file.content.contains("pub created_at: DateTime<Utc>"));
|
|
871
|
+
assert!(event_file.content.contains("pub updated_at: Option<DateTime<Utc>>"));
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
#[test]
|
|
875
|
+
fn test_generate_camel_case_fields_get_serde_rename() {
|
|
876
|
+
let mut type_map = HashMap::new();
|
|
877
|
+
type_map.insert(
|
|
878
|
+
"User".to_string(),
|
|
879
|
+
make_type("User", vec![
|
|
880
|
+
("id", "string", false),
|
|
881
|
+
("createdAt", "Date", false),
|
|
882
|
+
]),
|
|
883
|
+
);
|
|
884
|
+
|
|
885
|
+
let outputs = generate_structs(&type_map);
|
|
886
|
+
let user_file = outputs.iter().find(|o| o.path.contains("user.rs") && !o.path.contains("mod.rs")).unwrap();
|
|
887
|
+
assert!(!user_file.content.contains("#[serde(rename = \"id\")]"));
|
|
888
|
+
assert!(user_file.content.contains("#[serde(rename = \"createdAt\")]"));
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
#[test]
|
|
892
|
+
fn test_generate_mod_rs() {
|
|
893
|
+
let mut type_map = HashMap::new();
|
|
894
|
+
type_map.insert("User".to_string(), make_type("User", vec![("id", "string", false)]));
|
|
895
|
+
type_map.insert("Post".to_string(), make_type("Post", vec![("id", "string", false)]));
|
|
896
|
+
|
|
897
|
+
let outputs = generate_structs(&type_map);
|
|
898
|
+
let mod_file = outputs.iter().find(|o| o.path.ends_with("mod.rs")).unwrap();
|
|
899
|
+
assert!(mod_file.content.contains("pub mod common;"));
|
|
900
|
+
assert!(mod_file.content.contains("pub mod post;"));
|
|
901
|
+
assert!(mod_file.content.contains("pub mod user;"));
|
|
902
|
+
assert!(mod_file.content.contains("pub use post::Post;"));
|
|
903
|
+
assert!(mod_file.content.contains("pub use user::User;"));
|
|
904
|
+
assert!(mod_file.content.contains("pub use common::{PaginatedResponse, ErrorResponse};"));
|
|
905
|
+
assert!(mod_file.overwrite);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
#[test]
|
|
909
|
+
fn test_generate_file_paths() {
|
|
910
|
+
let mut type_map = HashMap::new();
|
|
911
|
+
type_map.insert(
|
|
912
|
+
"UserProfile".to_string(),
|
|
913
|
+
make_type("UserProfile", vec![("id", "string", false)]),
|
|
914
|
+
);
|
|
915
|
+
|
|
916
|
+
let outputs = generate_structs(&type_map);
|
|
917
|
+
let struct_file = outputs
|
|
918
|
+
.iter()
|
|
919
|
+
.find(|o| !o.path.ends_with("mod.rs") && !o.path.contains("common"))
|
|
920
|
+
.unwrap();
|
|
921
|
+
assert_eq!(struct_file.path, ".typokit/models/user_profile.rs");
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
#[test]
|
|
925
|
+
fn test_all_model_outputs_have_overwrite_true() {
|
|
926
|
+
let mut type_map = HashMap::new();
|
|
927
|
+
type_map.insert("User".to_string(), make_type("User", vec![("id", "string", false)]));
|
|
928
|
+
|
|
929
|
+
let outputs = generate_structs(&type_map);
|
|
930
|
+
for output in &outputs {
|
|
931
|
+
assert!(output.overwrite, "All generated model files should have overwrite: true");
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// ─── US-002: Validation annotation tests ─────────────────────────────────
|
|
936
|
+
|
|
937
|
+
#[test]
|
|
938
|
+
fn test_validation_min_max_length() {
|
|
939
|
+
let mut type_map = HashMap::new();
|
|
940
|
+
let mut properties = HashMap::new();
|
|
941
|
+
properties.insert(
|
|
942
|
+
"name".to_string(),
|
|
943
|
+
make_prop_with_jsdoc("string", false, vec![("minLength", "2"), ("maxLength", "100")]),
|
|
944
|
+
);
|
|
945
|
+
type_map.insert(
|
|
946
|
+
"User".to_string(),
|
|
947
|
+
TypeMetadata {
|
|
948
|
+
name: "User".to_string(),
|
|
949
|
+
properties,
|
|
950
|
+
jsdoc: None,
|
|
951
|
+
},
|
|
952
|
+
);
|
|
953
|
+
|
|
954
|
+
let outputs = generate_structs(&type_map);
|
|
955
|
+
let user_file = outputs.iter().find(|o| o.path.contains("user.rs") && !o.path.contains("mod.rs")).unwrap();
|
|
956
|
+
assert!(user_file.content.contains("#[validate(length(min = 2, max = 100))]"));
|
|
957
|
+
assert!(user_file.content.contains("Validate"));
|
|
958
|
+
assert!(user_file.content.contains("use validator::Validate;"));
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
#[test]
|
|
962
|
+
fn test_validation_email_format() {
|
|
963
|
+
let mut type_map = HashMap::new();
|
|
964
|
+
let mut properties = HashMap::new();
|
|
965
|
+
properties.insert(
|
|
966
|
+
"email".to_string(),
|
|
967
|
+
make_prop_with_jsdoc("string", false, vec![("format", "email")]),
|
|
968
|
+
);
|
|
969
|
+
type_map.insert(
|
|
970
|
+
"User".to_string(),
|
|
971
|
+
TypeMetadata {
|
|
972
|
+
name: "User".to_string(),
|
|
973
|
+
properties,
|
|
974
|
+
jsdoc: None,
|
|
975
|
+
},
|
|
976
|
+
);
|
|
977
|
+
|
|
978
|
+
let outputs = generate_structs(&type_map);
|
|
979
|
+
let user_file = outputs.iter().find(|o| o.path.contains("user.rs") && !o.path.contains("mod.rs")).unwrap();
|
|
980
|
+
assert!(user_file.content.contains("#[validate(email)]"));
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
#[test]
|
|
984
|
+
fn test_validation_regex_pattern() {
|
|
985
|
+
let mut type_map = HashMap::new();
|
|
986
|
+
let mut properties = HashMap::new();
|
|
987
|
+
properties.insert(
|
|
988
|
+
"phone".to_string(),
|
|
989
|
+
make_prop_with_jsdoc("string", false, vec![("pattern", r"^\+[0-9]{10,15}$")]),
|
|
990
|
+
);
|
|
991
|
+
type_map.insert(
|
|
992
|
+
"User".to_string(),
|
|
993
|
+
TypeMetadata {
|
|
994
|
+
name: "User".to_string(),
|
|
995
|
+
properties,
|
|
996
|
+
jsdoc: None,
|
|
997
|
+
},
|
|
998
|
+
);
|
|
999
|
+
|
|
1000
|
+
let outputs = generate_structs(&type_map);
|
|
1001
|
+
let user_file = outputs.iter().find(|o| o.path.contains("user.rs") && !o.path.contains("mod.rs")).unwrap();
|
|
1002
|
+
assert!(user_file.content.contains("use once_cell::sync::Lazy;"));
|
|
1003
|
+
assert!(user_file.content.contains("use regex::Regex;"));
|
|
1004
|
+
assert!(user_file.content.contains("static RE_USER_PHONE: Lazy<Regex>"));
|
|
1005
|
+
assert!(user_file.content.contains(r#"Regex::new(r"^\+[0-9]{10,15}$")"#));
|
|
1006
|
+
assert!(user_file.content.contains("#[validate(regex(path = \"*RE_USER_PHONE\"))]"));
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
#[test]
|
|
1010
|
+
fn test_validation_range_min_max() {
|
|
1011
|
+
let mut type_map = HashMap::new();
|
|
1012
|
+
let mut properties = HashMap::new();
|
|
1013
|
+
properties.insert(
|
|
1014
|
+
"score".to_string(),
|
|
1015
|
+
make_prop_with_jsdoc("number", false, vec![("minimum", "0"), ("maximum", "100")]),
|
|
1016
|
+
);
|
|
1017
|
+
type_map.insert(
|
|
1018
|
+
"Result".to_string(),
|
|
1019
|
+
TypeMetadata {
|
|
1020
|
+
name: "Result".to_string(),
|
|
1021
|
+
properties,
|
|
1022
|
+
jsdoc: None,
|
|
1023
|
+
},
|
|
1024
|
+
);
|
|
1025
|
+
|
|
1026
|
+
let outputs = generate_structs(&type_map);
|
|
1027
|
+
let result_file = outputs.iter().find(|o| o.path.contains("result.rs")).unwrap();
|
|
1028
|
+
assert!(result_file.content.contains("#[validate(range(min = 0, max = 100))]"));
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
#[test]
|
|
1032
|
+
fn test_validation_derive_only_when_needed() {
|
|
1033
|
+
let mut type_map = HashMap::new();
|
|
1034
|
+
type_map.insert(
|
|
1035
|
+
"Simple".to_string(),
|
|
1036
|
+
make_type("Simple", vec![("id", "string", false)]),
|
|
1037
|
+
);
|
|
1038
|
+
|
|
1039
|
+
let outputs = generate_structs(&type_map);
|
|
1040
|
+
let file = outputs.iter().find(|o| o.path.contains("simple.rs")).unwrap();
|
|
1041
|
+
assert!(!file.content.contains("Validate"));
|
|
1042
|
+
assert!(!file.content.contains("use validator::Validate;"));
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
// ─── US-002: Union literal enum tests ────────────────────────────────────
|
|
1046
|
+
|
|
1047
|
+
#[test]
|
|
1048
|
+
fn test_union_literal_detection() {
|
|
1049
|
+
assert!(is_union_literal("\"active\" | \"inactive\""));
|
|
1050
|
+
assert!(is_union_literal("\"active\" | \"archived\" | \"deleted\""));
|
|
1051
|
+
assert!(!is_union_literal("string"));
|
|
1052
|
+
assert!(!is_union_literal("string | number")); // not all string literals
|
|
1053
|
+
assert!(!is_union_literal("\"active\"")); // single literal, no union
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
#[test]
|
|
1057
|
+
fn test_parse_union_literals_extracts_variants() {
|
|
1058
|
+
let variants = parse_union_literals("\"active\" | \"archived\" | \"deleted\"");
|
|
1059
|
+
assert_eq!(variants, vec!["active", "archived", "deleted"]);
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
#[test]
|
|
1063
|
+
fn test_generate_enum_for_union_field() {
|
|
1064
|
+
let mut type_map = HashMap::new();
|
|
1065
|
+
let mut properties = HashMap::new();
|
|
1066
|
+
properties.insert("id".to_string(), make_prop("string", false));
|
|
1067
|
+
properties.insert(
|
|
1068
|
+
"status".to_string(),
|
|
1069
|
+
make_prop("\"active\" | \"archived\"", false),
|
|
1070
|
+
);
|
|
1071
|
+
type_map.insert(
|
|
1072
|
+
"Todo".to_string(),
|
|
1073
|
+
TypeMetadata {
|
|
1074
|
+
name: "Todo".to_string(),
|
|
1075
|
+
properties,
|
|
1076
|
+
jsdoc: None,
|
|
1077
|
+
},
|
|
1078
|
+
);
|
|
1079
|
+
|
|
1080
|
+
let outputs = generate_structs(&type_map);
|
|
1081
|
+
let todo_file = outputs.iter().find(|o| o.path.contains("todo.rs")).unwrap();
|
|
1082
|
+
assert!(todo_file.content.contains("pub enum TodoStatus"));
|
|
1083
|
+
assert!(todo_file.content.contains("#[serde(rename = \"active\")]"));
|
|
1084
|
+
assert!(todo_file.content.contains("Active,"));
|
|
1085
|
+
assert!(todo_file.content.contains("#[serde(rename = \"archived\")]"));
|
|
1086
|
+
assert!(todo_file.content.contains("Archived,"));
|
|
1087
|
+
assert!(todo_file.content.contains("pub status: TodoStatus"));
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
// ─── US-002: Integer inference tests ─────────────────────────────────────
|
|
1091
|
+
|
|
1092
|
+
#[test]
|
|
1093
|
+
fn test_integer_inference_pagination_params() {
|
|
1094
|
+
let empty_jsdoc: Option<HashMap<String, String>> = None;
|
|
1095
|
+
assert_eq!(
|
|
1096
|
+
ts_type_to_rust_with_context("number", "page", &empty_jsdoc),
|
|
1097
|
+
"u32"
|
|
1098
|
+
);
|
|
1099
|
+
assert_eq!(
|
|
1100
|
+
ts_type_to_rust_with_context("number", "pageSize", &empty_jsdoc),
|
|
1101
|
+
"u32"
|
|
1102
|
+
);
|
|
1103
|
+
assert_eq!(
|
|
1104
|
+
ts_type_to_rust_with_context("number", "limit", &empty_jsdoc),
|
|
1105
|
+
"u32"
|
|
1106
|
+
);
|
|
1107
|
+
assert_eq!(
|
|
1108
|
+
ts_type_to_rust_with_context("number", "offset", &empty_jsdoc),
|
|
1109
|
+
"u32"
|
|
1110
|
+
);
|
|
1111
|
+
assert_eq!(
|
|
1112
|
+
ts_type_to_rust_with_context("number", "total", &empty_jsdoc),
|
|
1113
|
+
"u32"
|
|
1114
|
+
);
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
#[test]
|
|
1118
|
+
fn test_integer_inference_general_number() {
|
|
1119
|
+
let empty_jsdoc: Option<HashMap<String, String>> = None;
|
|
1120
|
+
assert_eq!(
|
|
1121
|
+
ts_type_to_rust_with_context("number", "price", &empty_jsdoc),
|
|
1122
|
+
"f64"
|
|
1123
|
+
);
|
|
1124
|
+
assert_eq!(
|
|
1125
|
+
ts_type_to_rust_with_context("number", "amount", &empty_jsdoc),
|
|
1126
|
+
"f64"
|
|
1127
|
+
);
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
#[test]
|
|
1131
|
+
fn test_integer_inference_jsdoc_integer_override() {
|
|
1132
|
+
let mut jsdoc = HashMap::new();
|
|
1133
|
+
jsdoc.insert("integer".to_string(), "".to_string());
|
|
1134
|
+
let jsdoc = Some(jsdoc);
|
|
1135
|
+
assert_eq!(
|
|
1136
|
+
ts_type_to_rust_with_context("number", "quantity", &jsdoc),
|
|
1137
|
+
"i64"
|
|
1138
|
+
);
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
#[test]
|
|
1142
|
+
fn test_integer_inference_id_field_is_string() {
|
|
1143
|
+
let mut jsdoc = HashMap::new();
|
|
1144
|
+
jsdoc.insert("id".to_string(), "".to_string());
|
|
1145
|
+
let jsdoc = Some(jsdoc);
|
|
1146
|
+
assert_eq!(
|
|
1147
|
+
ts_type_to_rust_with_context("number", "id", &jsdoc),
|
|
1148
|
+
"String"
|
|
1149
|
+
);
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
#[test]
|
|
1153
|
+
fn test_integer_inference_in_struct() {
|
|
1154
|
+
let mut type_map = HashMap::new();
|
|
1155
|
+
let mut properties = HashMap::new();
|
|
1156
|
+
properties.insert(
|
|
1157
|
+
"page".to_string(),
|
|
1158
|
+
make_prop("number", false),
|
|
1159
|
+
);
|
|
1160
|
+
properties.insert(
|
|
1161
|
+
"pageSize".to_string(),
|
|
1162
|
+
make_prop("number", false),
|
|
1163
|
+
);
|
|
1164
|
+
properties.insert(
|
|
1165
|
+
"price".to_string(),
|
|
1166
|
+
make_prop("number", false),
|
|
1167
|
+
);
|
|
1168
|
+
type_map.insert(
|
|
1169
|
+
"Query".to_string(),
|
|
1170
|
+
TypeMetadata {
|
|
1171
|
+
name: "Query".to_string(),
|
|
1172
|
+
properties,
|
|
1173
|
+
jsdoc: None,
|
|
1174
|
+
},
|
|
1175
|
+
);
|
|
1176
|
+
|
|
1177
|
+
let outputs = generate_structs(&type_map);
|
|
1178
|
+
let query_file = outputs.iter().find(|o| o.path.contains("query.rs")).unwrap();
|
|
1179
|
+
assert!(query_file.content.contains("pub page: u32"));
|
|
1180
|
+
assert!(query_file.content.contains("pub page_size: u32"));
|
|
1181
|
+
assert!(query_file.content.contains("pub price: f64"));
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
// ─── US-002: Common types tests ──────────────────────────────────────────
|
|
1185
|
+
|
|
1186
|
+
#[test]
|
|
1187
|
+
fn test_common_types_generated() {
|
|
1188
|
+
let type_map = HashMap::new();
|
|
1189
|
+
let outputs = generate_structs(&type_map);
|
|
1190
|
+
let common_file = outputs.iter().find(|o| o.path.contains("common.rs")).unwrap();
|
|
1191
|
+
assert!(common_file.content.contains("pub struct PaginatedResponse<T>"));
|
|
1192
|
+
assert!(common_file.content.contains("pub data: Vec<T>"));
|
|
1193
|
+
assert!(common_file.content.contains("pub total: u32"));
|
|
1194
|
+
assert!(common_file.content.contains("pub struct ErrorResponse"));
|
|
1195
|
+
assert!(common_file.content.contains("pub error: String"));
|
|
1196
|
+
assert!(common_file.content.contains("pub message: String"));
|
|
1197
|
+
assert!(common_file.overwrite);
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
// ─── US-002: Utility type resolution tests ───────────────────────────────
|
|
1201
|
+
|
|
1202
|
+
#[test]
|
|
1203
|
+
fn test_resolve_omit() {
|
|
1204
|
+
let base = make_type("User", vec![
|
|
1205
|
+
("id", "string", false),
|
|
1206
|
+
("name", "string", false),
|
|
1207
|
+
("email", "string", false),
|
|
1208
|
+
("createdAt", "Date", false),
|
|
1209
|
+
]);
|
|
1210
|
+
|
|
1211
|
+
let resolved = resolve_omit(
|
|
1212
|
+
&base,
|
|
1213
|
+
&["id".to_string(), "createdAt".to_string()],
|
|
1214
|
+
"CreateUserInput",
|
|
1215
|
+
);
|
|
1216
|
+
assert_eq!(resolved.name, "CreateUserInput");
|
|
1217
|
+
assert_eq!(resolved.properties.len(), 2);
|
|
1218
|
+
assert!(resolved.properties.contains_key("name"));
|
|
1219
|
+
assert!(resolved.properties.contains_key("email"));
|
|
1220
|
+
assert!(!resolved.properties.contains_key("id"));
|
|
1221
|
+
assert!(!resolved.properties.contains_key("createdAt"));
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
#[test]
|
|
1225
|
+
fn test_resolve_partial() {
|
|
1226
|
+
let base = make_type("User", vec![
|
|
1227
|
+
("id", "string", false),
|
|
1228
|
+
("name", "string", false),
|
|
1229
|
+
("email", "string", false),
|
|
1230
|
+
]);
|
|
1231
|
+
|
|
1232
|
+
let resolved = resolve_partial(&base, "PartialUser");
|
|
1233
|
+
assert_eq!(resolved.name, "PartialUser");
|
|
1234
|
+
assert_eq!(resolved.properties.len(), 3);
|
|
1235
|
+
for (_name, prop) in &resolved.properties {
|
|
1236
|
+
assert!(prop.optional, "All fields in Partial<T> should be optional");
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
#[test]
|
|
1241
|
+
fn test_resolve_pick() {
|
|
1242
|
+
let base = make_type("User", vec![
|
|
1243
|
+
("id", "string", false),
|
|
1244
|
+
("name", "string", false),
|
|
1245
|
+
("email", "string", false),
|
|
1246
|
+
("createdAt", "Date", false),
|
|
1247
|
+
]);
|
|
1248
|
+
|
|
1249
|
+
let resolved = resolve_pick(
|
|
1250
|
+
&base,
|
|
1251
|
+
&["name".to_string(), "email".to_string()],
|
|
1252
|
+
"UserPickNameEmail",
|
|
1253
|
+
);
|
|
1254
|
+
assert_eq!(resolved.name, "UserPickNameEmail");
|
|
1255
|
+
assert_eq!(resolved.properties.len(), 2);
|
|
1256
|
+
assert!(resolved.properties.contains_key("name"));
|
|
1257
|
+
assert!(resolved.properties.contains_key("email"));
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
#[test]
|
|
1261
|
+
fn test_parse_utility_type_omit() {
|
|
1262
|
+
let result = parse_utility_type("Omit<User, \"id\" | \"createdAt\">");
|
|
1263
|
+
assert!(result.is_some());
|
|
1264
|
+
let (utility, base, args) = result.unwrap();
|
|
1265
|
+
assert_eq!(utility, "Omit");
|
|
1266
|
+
assert_eq!(base, "User");
|
|
1267
|
+
assert_eq!(args, vec!["id", "createdAt"]);
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
#[test]
|
|
1271
|
+
fn test_parse_utility_type_partial() {
|
|
1272
|
+
let result = parse_utility_type("Partial<User>");
|
|
1273
|
+
assert!(result.is_some());
|
|
1274
|
+
let (utility, base, args) = result.unwrap();
|
|
1275
|
+
assert_eq!(utility, "Partial");
|
|
1276
|
+
assert_eq!(base, "User");
|
|
1277
|
+
assert!(args.is_empty());
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
#[test]
|
|
1281
|
+
fn test_parse_utility_type_pick() {
|
|
1282
|
+
let result = parse_utility_type("Pick<User, \"name\" | \"email\">");
|
|
1283
|
+
assert!(result.is_some());
|
|
1284
|
+
let (utility, base, args) = result.unwrap();
|
|
1285
|
+
assert_eq!(utility, "Pick");
|
|
1286
|
+
assert_eq!(base, "User");
|
|
1287
|
+
assert_eq!(args, vec!["name", "email"]);
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
#[test]
|
|
1291
|
+
fn test_parse_utility_type_returns_none_for_regular_types() {
|
|
1292
|
+
assert!(parse_utility_type("string").is_none());
|
|
1293
|
+
assert!(parse_utility_type("User").is_none());
|
|
1294
|
+
assert!(parse_utility_type("Vec<String>").is_none());
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
#[test]
|
|
1298
|
+
fn test_utility_type_name_generation() {
|
|
1299
|
+
assert_eq!(
|
|
1300
|
+
utility_type_name("Omit", "User", &["id".to_string(), "createdAt".to_string()]),
|
|
1301
|
+
"UserWithoutIdCreatedAt"
|
|
1302
|
+
);
|
|
1303
|
+
assert_eq!(utility_type_name("Partial", "User", &[]), "PartialUser");
|
|
1304
|
+
assert_eq!(
|
|
1305
|
+
utility_type_name("Pick", "User", &["name".to_string(), "email".to_string()]),
|
|
1306
|
+
"UserPickNameEmail"
|
|
1307
|
+
);
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
#[test]
|
|
1311
|
+
fn test_utility_type_in_property_generates_resolved_struct() {
|
|
1312
|
+
let mut type_map = HashMap::new();
|
|
1313
|
+
type_map.insert(
|
|
1314
|
+
"User".to_string(),
|
|
1315
|
+
make_type("User", vec![
|
|
1316
|
+
("id", "string", false),
|
|
1317
|
+
("name", "string", false),
|
|
1318
|
+
("email", "string", false),
|
|
1319
|
+
]),
|
|
1320
|
+
);
|
|
1321
|
+
|
|
1322
|
+
let mut route_properties = HashMap::new();
|
|
1323
|
+
route_properties.insert(
|
|
1324
|
+
"body".to_string(),
|
|
1325
|
+
make_prop("Omit<User, \"id\">", false),
|
|
1326
|
+
);
|
|
1327
|
+
type_map.insert(
|
|
1328
|
+
"CreateRoute".to_string(),
|
|
1329
|
+
TypeMetadata {
|
|
1330
|
+
name: "CreateRoute".to_string(),
|
|
1331
|
+
properties: route_properties,
|
|
1332
|
+
jsdoc: None,
|
|
1333
|
+
},
|
|
1334
|
+
);
|
|
1335
|
+
|
|
1336
|
+
let outputs = generate_structs(&type_map);
|
|
1337
|
+
|
|
1338
|
+
// Should generate a UserWithoutId struct
|
|
1339
|
+
let resolved_file = outputs
|
|
1340
|
+
.iter()
|
|
1341
|
+
.find(|o| o.path.contains("user_without_id.rs"));
|
|
1342
|
+
assert!(resolved_file.is_some(), "Should generate resolved utility type struct");
|
|
1343
|
+
let content = &resolved_file.unwrap().content;
|
|
1344
|
+
assert!(content.contains("pub struct UserWithoutId"));
|
|
1345
|
+
assert!(content.contains("pub name: String"));
|
|
1346
|
+
assert!(content.contains("pub email: String"));
|
|
1347
|
+
assert!(!content.contains("pub id:"));
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
#[test]
|
|
1351
|
+
fn test_enum_variant_to_pascal() {
|
|
1352
|
+
assert_eq!(variant_to_pascal("active"), "Active");
|
|
1353
|
+
assert_eq!(variant_to_pascal("in_progress"), "InProgress");
|
|
1354
|
+
assert_eq!(variant_to_pascal("not-started"), "NotStarted");
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
#[test]
|
|
1358
|
+
fn test_to_screaming_snake_case() {
|
|
1359
|
+
assert_eq!(to_screaming_snake_case("User"), "USER");
|
|
1360
|
+
assert_eq!(to_screaming_snake_case("createdAt"), "CREATED_AT");
|
|
1361
|
+
assert_eq!(to_screaming_snake_case("phone"), "PHONE");
|
|
1362
|
+
}
|
|
1363
|
+
}
|