@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.
@@ -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
+ }