@typokit/transform-native 0.1.4

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,318 @@
1
+ use crate::route_compiler::{RouteEntry, RouteTypeInfo, RouteObjectProp};
2
+
3
+ /// Generate contract test scaffolding from route entries.
4
+ ///
5
+ /// Produces TypeScript test code using describe/it blocks that test:
6
+ /// - Valid request payloads return success
7
+ /// - Missing required fields return 400
8
+ /// - Invalid types return 400
9
+ pub fn generate_test_stubs(routes: &[RouteEntry]) -> String {
10
+ let mut output = String::new();
11
+
12
+ output.push_str("// AUTO-GENERATED by @typokit/transform-native — DO NOT EDIT\n");
13
+ output.push_str("// Contract tests generated from route definitions\n");
14
+ output.push_str("import { describe, it, expect } from \"@rstest/core\";\n");
15
+ output.push_str("import { createTestClient } from \"@typokit/testing\";\n\n");
16
+ output.push_str("const client = createTestClient();\n\n");
17
+
18
+ for entry in routes {
19
+ generate_route_test(&mut output, entry);
20
+ }
21
+
22
+ output
23
+ }
24
+
25
+ fn generate_route_test(output: &mut String, entry: &RouteEntry) {
26
+ let label = format!("{} {}", entry.method, entry.path);
27
+ output.push_str(&format!("describe(\"{}\", () => {{\n", label));
28
+
29
+ // Test 1: Valid request
30
+ generate_valid_request_test(output, entry);
31
+
32
+ // Test 2: If body has required fields, test missing fields
33
+ if has_required_fields(&entry.body_type) {
34
+ generate_missing_fields_test(output, entry);
35
+ }
36
+
37
+ // Test 3: If params exist, test with valid params
38
+ if !matches!(entry.params_type, RouteTypeInfo::Void) {
39
+ generate_params_test(output, entry);
40
+ }
41
+
42
+ output.push_str("});\n\n");
43
+ }
44
+
45
+ fn generate_valid_request_test(output: &mut String, entry: &RouteEntry) {
46
+ let method_lower = entry.method.to_lowercase();
47
+ output.push_str(" it(\"accepts valid request\", async () => {\n");
48
+
49
+ let path = convert_path_params(&entry.path);
50
+ let has_body = !matches!(entry.body_type, RouteTypeInfo::Void);
51
+ let has_query = !matches!(entry.query_type, RouteTypeInfo::Void);
52
+
53
+ if has_body || has_query {
54
+ output.push_str(&format!(" const res = await client.{}(\"{}\", {{\n", method_lower, path));
55
+ if has_body {
56
+ output.push_str(" body: ");
57
+ output.push_str(&generate_sample_value(&entry.body_type, 3));
58
+ output.push_str(",\n");
59
+ }
60
+ if has_query {
61
+ output.push_str(" query: ");
62
+ output.push_str(&generate_sample_value(&entry.query_type, 3));
63
+ output.push_str(",\n");
64
+ }
65
+ output.push_str(" });\n");
66
+ } else {
67
+ output.push_str(&format!(" const res = await client.{}(\"{}\");\n", method_lower, path));
68
+ }
69
+
70
+ output.push_str(" expect(res.status).toBe(200);\n");
71
+ output.push_str(" });\n\n");
72
+ }
73
+
74
+ fn generate_missing_fields_test(output: &mut String, entry: &RouteEntry) {
75
+ let method_lower = entry.method.to_lowercase();
76
+ let path = convert_path_params(&entry.path);
77
+
78
+ output.push_str(" it(\"rejects missing required fields\", async () => {\n");
79
+ output.push_str(&format!(
80
+ " const res = await client.{}(\"{}\", {{\n body: {{}},\n }});\n",
81
+ method_lower, path
82
+ ));
83
+ output.push_str(" expect(res.status).toBe(400);\n");
84
+ output.push_str(" });\n\n");
85
+ }
86
+
87
+ fn generate_params_test(output: &mut String, entry: &RouteEntry) {
88
+ let method_lower = entry.method.to_lowercase();
89
+ let path = convert_path_params(&entry.path);
90
+
91
+ output.push_str(" it(\"handles path parameters\", async () => {\n");
92
+ output.push_str(&format!(
93
+ " const res = await client.{}(\"{}\");\n",
94
+ method_lower, path
95
+ ));
96
+ output.push_str(" expect(res.status).toBeDefined();\n");
97
+ output.push_str(" });\n\n");
98
+ }
99
+
100
+ /// Convert route path params (`:id`) to sample values for test URLs
101
+ fn convert_path_params(path: &str) -> String {
102
+ let mut result = String::new();
103
+ for segment in path.split('/') {
104
+ if segment.is_empty() {
105
+ continue;
106
+ }
107
+ result.push('/');
108
+ if segment.starts_with(':') {
109
+ result.push_str("test-id");
110
+ } else if segment.starts_with('*') {
111
+ result.push_str("test/wildcard/path");
112
+ } else {
113
+ result.push_str(segment);
114
+ }
115
+ }
116
+ if result.is_empty() {
117
+ result.push('/');
118
+ }
119
+ result
120
+ }
121
+
122
+ /// Check if a type has required (non-optional) fields
123
+ fn has_required_fields(type_info: &RouteTypeInfo) -> bool {
124
+ match type_info {
125
+ RouteTypeInfo::ObjectLiteral(props) => props.iter().any(|p| !p.optional),
126
+ RouteTypeInfo::Named(_) => true, // Assume named types have required fields
127
+ _ => false,
128
+ }
129
+ }
130
+
131
+ /// Generate a sample value string for a given type
132
+ fn generate_sample_value(type_info: &RouteTypeInfo, indent: usize) -> String {
133
+ match type_info {
134
+ RouteTypeInfo::Void => "undefined".to_string(),
135
+ RouteTypeInfo::Primitive(p) => match p.as_str() {
136
+ "string" => "\"test-value\"".to_string(),
137
+ "number" => "42".to_string(),
138
+ "boolean" => "true".to_string(),
139
+ _ => "null".to_string(),
140
+ },
141
+ RouteTypeInfo::Named(name) => format!("{{}} as unknown as {}", name),
142
+ RouteTypeInfo::ObjectLiteral(props) => {
143
+ generate_object_sample(props, indent)
144
+ }
145
+ RouteTypeInfo::Array(inner) => {
146
+ format!("[{}]", generate_sample_value(inner, indent))
147
+ }
148
+ RouteTypeInfo::StringLiteral(s) => format!("\"{}\"", s),
149
+ RouteTypeInfo::NumberLiteral(n) => format!("{}", n),
150
+ RouteTypeInfo::BooleanLiteral(b) => format!("{}", b),
151
+ RouteTypeInfo::Union(variants) => {
152
+ // Use first variant as sample
153
+ if let Some(first) = variants.first() {
154
+ generate_sample_value(first, indent)
155
+ } else {
156
+ "null".to_string()
157
+ }
158
+ }
159
+ RouteTypeInfo::Generic(name, _args) => {
160
+ format!("{{}} as unknown as {}", name)
161
+ }
162
+ }
163
+ }
164
+
165
+ fn generate_object_sample(props: &[RouteObjectProp], indent: usize) -> String {
166
+ if props.is_empty() {
167
+ return "{}".to_string();
168
+ }
169
+
170
+ let indent_str = " ".repeat(indent);
171
+ let inner_indent = " ".repeat(indent + 1);
172
+ let mut parts = Vec::new();
173
+
174
+ for prop in props {
175
+ if !prop.optional {
176
+ parts.push(format!(
177
+ "{}{}: {}",
178
+ inner_indent,
179
+ prop.name,
180
+ generate_sample_value(&prop.type_info, indent + 1)
181
+ ));
182
+ }
183
+ }
184
+
185
+ if parts.is_empty() {
186
+ "{}".to_string()
187
+ } else {
188
+ format!("{{\n{},\n{}}}", parts.join(",\n"), indent_str)
189
+ }
190
+ }
191
+
192
+ #[cfg(test)]
193
+ mod tests {
194
+ use super::*;
195
+ use crate::route_compiler::{parse_path_segments, RouteEntry, RouteTypeInfo, RouteObjectProp};
196
+
197
+ fn make_route(method: &str, path: &str, body: RouteTypeInfo) -> RouteEntry {
198
+ RouteEntry {
199
+ method: method.to_string(),
200
+ path: path.to_string(),
201
+ segments: parse_path_segments(path),
202
+ handler_ref: format!("Test#{} {}", method, path),
203
+ params_type: if path.contains(':') {
204
+ RouteTypeInfo::ObjectLiteral(vec![RouteObjectProp {
205
+ name: "id".to_string(),
206
+ type_info: RouteTypeInfo::Primitive("string".into()),
207
+ optional: false,
208
+ }])
209
+ } else {
210
+ RouteTypeInfo::Void
211
+ },
212
+ query_type: RouteTypeInfo::Void,
213
+ body_type: body,
214
+ response_type: RouteTypeInfo::Void,
215
+ }
216
+ }
217
+
218
+ #[test]
219
+ fn test_generate_stubs_basic() {
220
+ let routes = vec![
221
+ make_route("GET", "/users", RouteTypeInfo::Void),
222
+ ];
223
+
224
+ let output = generate_test_stubs(&routes);
225
+
226
+ assert!(output.contains("AUTO-GENERATED"));
227
+ assert!(output.contains("describe(\"GET /users\""));
228
+ assert!(output.contains("accepts valid request"));
229
+ assert!(output.contains("client.get(\"/users\")"));
230
+ }
231
+
232
+ #[test]
233
+ fn test_generate_stubs_with_body() {
234
+ let body = RouteTypeInfo::ObjectLiteral(vec![
235
+ RouteObjectProp {
236
+ name: "email".to_string(),
237
+ type_info: RouteTypeInfo::Primitive("string".into()),
238
+ optional: false,
239
+ },
240
+ RouteObjectProp {
241
+ name: "name".to_string(),
242
+ type_info: RouteTypeInfo::Primitive("string".into()),
243
+ optional: false,
244
+ },
245
+ ]);
246
+ let routes = vec![
247
+ make_route("POST", "/users", body),
248
+ ];
249
+
250
+ let output = generate_test_stubs(&routes);
251
+
252
+ assert!(output.contains("client.post(\"/users\""));
253
+ assert!(output.contains("body:"));
254
+ assert!(output.contains("email:"));
255
+ assert!(output.contains("rejects missing required fields"));
256
+ assert!(output.contains("body: {}"));
257
+ }
258
+
259
+ #[test]
260
+ fn test_generate_stubs_with_params() {
261
+ let routes = vec![
262
+ make_route("GET", "/users/:id", RouteTypeInfo::Void),
263
+ ];
264
+
265
+ let output = generate_test_stubs(&routes);
266
+
267
+ assert!(output.contains("GET /users/:id"));
268
+ assert!(output.contains("test-id"));
269
+ assert!(output.contains("handles path parameters"));
270
+ }
271
+
272
+ #[test]
273
+ fn test_generate_stubs_multiple_routes() {
274
+ let routes = vec![
275
+ make_route("GET", "/users", RouteTypeInfo::Void),
276
+ make_route("POST", "/users", RouteTypeInfo::ObjectLiteral(vec![
277
+ RouteObjectProp {
278
+ name: "email".to_string(),
279
+ type_info: RouteTypeInfo::Primitive("string".into()),
280
+ optional: false,
281
+ },
282
+ ])),
283
+ make_route("DELETE", "/users/:id", RouteTypeInfo::Void),
284
+ ];
285
+
286
+ let output = generate_test_stubs(&routes);
287
+
288
+ assert!(output.contains("GET /users"));
289
+ assert!(output.contains("POST /users"));
290
+ assert!(output.contains("DELETE /users/:id"));
291
+ }
292
+
293
+ #[test]
294
+ fn test_convert_path_params() {
295
+ assert_eq!(convert_path_params("/users"), "/users");
296
+ assert_eq!(convert_path_params("/users/:id"), "/users/test-id");
297
+ assert_eq!(convert_path_params("/users/:userId/posts/:postId"), "/users/test-id/posts/test-id");
298
+ assert_eq!(convert_path_params("/files/*path"), "/files/test/wildcard/path");
299
+ }
300
+
301
+ #[test]
302
+ fn test_generate_sample_value_primitives() {
303
+ assert_eq!(generate_sample_value(&RouteTypeInfo::Primitive("string".into()), 0), "\"test-value\"");
304
+ assert_eq!(generate_sample_value(&RouteTypeInfo::Primitive("number".into()), 0), "42");
305
+ assert_eq!(generate_sample_value(&RouteTypeInfo::Primitive("boolean".into()), 0), "true");
306
+ assert_eq!(generate_sample_value(&RouteTypeInfo::Void, 0), "undefined");
307
+ }
308
+
309
+ #[test]
310
+ fn test_generate_imports() {
311
+ let routes = vec![make_route("GET", "/health", RouteTypeInfo::Void)];
312
+ let output = generate_test_stubs(&routes);
313
+
314
+ assert!(output.contains("import { describe, it, expect }"));
315
+ assert!(output.contains("import { createTestClient }"));
316
+ assert!(output.contains("const client = createTestClient()"));
317
+ }
318
+ }
@@ -0,0 +1,370 @@
1
+ use std::collections::HashMap;
2
+ use serde::{Deserialize, Serialize};
3
+ use swc_common::comments::{Comment, CommentKind, SingleThreadedComments, Comments};
4
+ use swc_common::Spanned;
5
+ use swc_ecma_ast::*;
6
+
7
+ /// Metadata about a single property in a type
8
+ #[derive(Debug, Clone, Serialize, Deserialize)]
9
+ pub struct PropertyMetadata {
10
+ #[serde(rename = "type")]
11
+ pub type_str: String,
12
+ pub optional: bool,
13
+ #[serde(skip_serializing_if = "Option::is_none")]
14
+ pub jsdoc: Option<HashMap<String, String>>,
15
+ }
16
+
17
+ /// Metadata about a single extracted type (matches @typokit/types TypeMetadata)
18
+ #[derive(Debug, Clone, Serialize, Deserialize)]
19
+ pub struct TypeMetadata {
20
+ pub name: String,
21
+ pub properties: HashMap<String, PropertyMetadata>,
22
+ #[serde(skip_serializing_if = "Option::is_none")]
23
+ pub jsdoc: Option<HashMap<String, String>>,
24
+ }
25
+
26
+ /// Extract all JSDoc tags from a comment block
27
+ fn parse_jsdoc_tags(comment: &str) -> HashMap<String, String> {
28
+ let mut tags: HashMap<String, String> = HashMap::new();
29
+
30
+ for line in comment.lines() {
31
+ let trimmed = line.trim().trim_start_matches('*').trim();
32
+ // Find all @tag occurrences on this line
33
+ let mut rest = trimmed;
34
+ while let Some(at_pos) = rest.find('@') {
35
+ rest = &rest[at_pos + 1..];
36
+ let parts: Vec<&str> = rest.splitn(2, char::is_whitespace).collect();
37
+ let tag_name = parts[0].to_string();
38
+ let tag_value_str = parts.get(1).map(|s| s.trim()).unwrap_or("");
39
+ // The tag value is everything up to the next @tag or end of line
40
+ let value = if let Some(next_at) = tag_value_str.find('@') {
41
+ tag_value_str[..next_at].trim().to_string()
42
+ } else {
43
+ tag_value_str.to_string()
44
+ };
45
+ tags.insert(tag_name, value);
46
+ // Advance rest past this tag's value to find next @
47
+ rest = parts.get(1).map(|s| *s).unwrap_or("");
48
+ }
49
+ }
50
+
51
+ tags
52
+ }
53
+
54
+ /// Get leading comments for a span position
55
+ fn get_leading_comments(comments: &SingleThreadedComments, span: &dyn Spanned) -> Vec<Comment> {
56
+ let lo = span.span().lo;
57
+ comments.get_leading(lo).unwrap_or_default()
58
+ }
59
+
60
+ /// Convert a TypeScript type annotation to a string representation
61
+ fn ts_type_to_string(ts_type: &TsType) -> String {
62
+ match ts_type {
63
+ TsType::TsKeywordType(kw) => match kw.kind {
64
+ TsKeywordTypeKind::TsStringKeyword => "string".to_string(),
65
+ TsKeywordTypeKind::TsNumberKeyword => "number".to_string(),
66
+ TsKeywordTypeKind::TsBooleanKeyword => "boolean".to_string(),
67
+ TsKeywordTypeKind::TsVoidKeyword => "void".to_string(),
68
+ TsKeywordTypeKind::TsNullKeyword => "null".to_string(),
69
+ TsKeywordTypeKind::TsUndefinedKeyword => "undefined".to_string(),
70
+ TsKeywordTypeKind::TsAnyKeyword => "any".to_string(),
71
+ TsKeywordTypeKind::TsUnknownKeyword => "unknown".to_string(),
72
+ TsKeywordTypeKind::TsNeverKeyword => "never".to_string(),
73
+ TsKeywordTypeKind::TsBigIntKeyword => "bigint".to_string(),
74
+ TsKeywordTypeKind::TsSymbolKeyword => "symbol".to_string(),
75
+ TsKeywordTypeKind::TsObjectKeyword => "object".to_string(),
76
+ _ => "unknown".to_string(),
77
+ },
78
+ TsType::TsTypeRef(type_ref) => {
79
+ let name = match &type_ref.type_name {
80
+ TsEntityName::Ident(ident) => ident.sym.to_string(),
81
+ TsEntityName::TsQualifiedName(qn) => format!("{}.{}", ts_entity_name_to_string(&TsEntityName::TsQualifiedName(qn.clone())), ""),
82
+ };
83
+ if let Some(type_params) = &type_ref.type_params {
84
+ let params: Vec<String> = type_params.params.iter().map(|p| ts_type_to_string(p)).collect();
85
+ format!("{}<{}>", name, params.join(", "))
86
+ } else {
87
+ name
88
+ }
89
+ }
90
+ TsType::TsArrayType(arr) => format!("{}[]", ts_type_to_string(&arr.elem_type)),
91
+ TsType::TsUnionOrIntersectionType(u) => match u {
92
+ TsUnionOrIntersectionType::TsUnionType(union) => {
93
+ let types: Vec<String> = union.types.iter().map(|t| ts_type_to_string(t)).collect();
94
+ types.join(" | ")
95
+ }
96
+ TsUnionOrIntersectionType::TsIntersectionType(inter) => {
97
+ let types: Vec<String> = inter.types.iter().map(|t| ts_type_to_string(t)).collect();
98
+ types.join(" & ")
99
+ }
100
+ },
101
+ TsType::TsLitType(lit) => match &lit.lit {
102
+ TsLit::Str(s) => format!("\"{}\"", s.value.to_string_lossy()),
103
+ TsLit::Number(n) => n.value.to_string(),
104
+ TsLit::Bool(b) => b.value.to_string(),
105
+ _ => "unknown".to_string(),
106
+ },
107
+ TsType::TsParenthesizedType(paren) => format!("({})", ts_type_to_string(&paren.type_ann)),
108
+ TsType::TsOptionalType(opt) => format!("{}?", ts_type_to_string(&opt.type_ann)),
109
+ TsType::TsTupleType(tuple) => {
110
+ let elems: Vec<String> = tuple.elem_types.iter().map(|e| ts_type_to_string(&e.ty)).collect();
111
+ format!("[{}]", elems.join(", "))
112
+ }
113
+ TsType::TsFnOrConstructorType(_) => "Function".to_string(),
114
+ TsType::TsTypeLit(_) => "object".to_string(),
115
+ _ => "unknown".to_string(),
116
+ }
117
+ }
118
+
119
+ /// Helper to convert TsEntityName to string
120
+ fn ts_entity_name_to_string(name: &TsEntityName) -> String {
121
+ match name {
122
+ TsEntityName::Ident(ident) => ident.sym.to_string(),
123
+ TsEntityName::TsQualifiedName(qn) => {
124
+ format!("{}.{}", ts_entity_name_to_string(&qn.left), qn.right.sym)
125
+ }
126
+ }
127
+ }
128
+
129
+ /// Extract type metadata from all interfaces in a module
130
+ pub fn extract_types(module: &Module, comments: &SingleThreadedComments) -> HashMap<String, TypeMetadata> {
131
+ let mut types: HashMap<String, TypeMetadata> = HashMap::new();
132
+
133
+ for item in &module.body {
134
+ match item {
135
+ ModuleItem::Stmt(Stmt::Decl(Decl::TsInterface(iface))) => {
136
+ let metadata = extract_interface(iface, comments);
137
+ types.insert(metadata.name.clone(), metadata);
138
+ }
139
+ ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(export)) => {
140
+ if let Decl::TsInterface(iface) = &export.decl {
141
+ let metadata = extract_interface(iface, comments);
142
+ types.insert(metadata.name.clone(), metadata);
143
+ }
144
+ }
145
+ _ => {}
146
+ }
147
+ }
148
+
149
+ types
150
+ }
151
+
152
+ /// Extract metadata from a single interface declaration
153
+ fn extract_interface(iface: &TsInterfaceDecl, comments: &SingleThreadedComments) -> TypeMetadata {
154
+ let name = iface.id.sym.to_string();
155
+
156
+ // Extract interface-level JSDoc tags
157
+ let leading = get_leading_comments(comments, iface);
158
+ let iface_jsdoc = extract_jsdoc_from_comments(&leading);
159
+
160
+ let mut properties: HashMap<String, PropertyMetadata> = HashMap::new();
161
+
162
+ for member in &iface.body.body {
163
+ if let TsTypeElement::TsPropertySignature(prop) = member {
164
+ let prop_name = match &*prop.key {
165
+ Expr::Ident(ident) => ident.sym.to_string(),
166
+ _ => continue,
167
+ };
168
+
169
+ let type_str = prop
170
+ .type_ann
171
+ .as_ref()
172
+ .map(|ann| ts_type_to_string(&ann.type_ann))
173
+ .unwrap_or_else(|| "unknown".to_string());
174
+
175
+ let optional = prop.optional;
176
+
177
+ // Extract property-level JSDoc tags
178
+ let prop_leading = get_leading_comments(comments, prop);
179
+ let prop_jsdoc = extract_jsdoc_from_comments(&prop_leading);
180
+
181
+ properties.insert(
182
+ prop_name,
183
+ PropertyMetadata {
184
+ type_str,
185
+ optional,
186
+ jsdoc: if prop_jsdoc.is_empty() { None } else { Some(prop_jsdoc) },
187
+ },
188
+ );
189
+ }
190
+ }
191
+
192
+ TypeMetadata {
193
+ name,
194
+ properties,
195
+ jsdoc: if iface_jsdoc.is_empty() { None } else { Some(iface_jsdoc) },
196
+ }
197
+ }
198
+
199
+ /// Extract JSDoc tags from comment list
200
+ fn extract_jsdoc_from_comments(comments: &[Comment]) -> HashMap<String, String> {
201
+ let mut tags = HashMap::new();
202
+ for comment in comments {
203
+ if comment.kind == CommentKind::Block {
204
+ let parsed = parse_jsdoc_tags(&comment.text);
205
+ tags.extend(parsed);
206
+ }
207
+ }
208
+ tags
209
+ }
210
+
211
+ #[cfg(test)]
212
+ mod tests {
213
+ use super::*;
214
+ use crate::parser::parse_typescript;
215
+
216
+ #[test]
217
+ fn test_extract_simple_interface() {
218
+ let source = r#"
219
+ interface User {
220
+ id: string;
221
+ name: string;
222
+ age: number;
223
+ active: boolean;
224
+ }
225
+ "#;
226
+ let parsed = parse_typescript("test.ts", source).unwrap();
227
+ let types = extract_types(&parsed.module, &parsed.comments);
228
+
229
+ assert!(types.contains_key("User"));
230
+ let user = &types["User"];
231
+ assert_eq!(user.name, "User");
232
+ assert_eq!(user.properties.len(), 4);
233
+ assert_eq!(user.properties["id"].type_str, "string");
234
+ assert_eq!(user.properties["age"].type_str, "number");
235
+ assert_eq!(user.properties["active"].type_str, "boolean");
236
+ assert!(!user.properties["id"].optional);
237
+ }
238
+
239
+ #[test]
240
+ fn test_extract_optional_properties() {
241
+ let source = r#"
242
+ interface Profile {
243
+ bio?: string;
244
+ avatar?: string;
245
+ required: number;
246
+ }
247
+ "#;
248
+ let parsed = parse_typescript("test.ts", source).unwrap();
249
+ let types = extract_types(&parsed.module, &parsed.comments);
250
+
251
+ let profile = &types["Profile"];
252
+ assert!(profile.properties["bio"].optional);
253
+ assert!(profile.properties["avatar"].optional);
254
+ assert!(!profile.properties["required"].optional);
255
+ }
256
+
257
+ #[test]
258
+ fn test_extract_jsdoc_table_tag() {
259
+ let source = r#"
260
+ /**
261
+ * @table users
262
+ */
263
+ interface User {
264
+ /** @id */
265
+ id: string;
266
+ name: string;
267
+ }
268
+ "#;
269
+ let parsed = parse_typescript("test.ts", source).unwrap();
270
+ let types = extract_types(&parsed.module, &parsed.comments);
271
+
272
+ let user = &types["User"];
273
+ let jsdoc = user.jsdoc.as_ref().unwrap();
274
+ assert_eq!(jsdoc["table"], "users");
275
+
276
+ let id_jsdoc = user.properties["id"].jsdoc.as_ref().unwrap();
277
+ assert!(id_jsdoc.contains_key("id"));
278
+ }
279
+
280
+ #[test]
281
+ fn test_extract_all_jsdoc_tags() {
282
+ let source = r#"
283
+ /**
284
+ * @table users
285
+ */
286
+ interface User {
287
+ /** @id @generated */
288
+ id: string;
289
+ /** @format email @unique */
290
+ email: string;
291
+ /** @minLength 2 @maxLength 100 */
292
+ name: string;
293
+ /** @default now() @onUpdate now() */
294
+ updatedAt: string;
295
+ }
296
+ "#;
297
+ let parsed = parse_typescript("test.ts", source).unwrap();
298
+ let types = extract_types(&parsed.module, &parsed.comments);
299
+
300
+ let user = &types["User"];
301
+ let id_jsdoc = user.properties["id"].jsdoc.as_ref().unwrap();
302
+ assert!(id_jsdoc.contains_key("id"));
303
+ assert!(id_jsdoc.contains_key("generated"));
304
+
305
+ let email_jsdoc = user.properties["email"].jsdoc.as_ref().unwrap();
306
+ assert_eq!(email_jsdoc["format"], "email");
307
+ assert!(email_jsdoc.contains_key("unique"));
308
+
309
+ let name_jsdoc = user.properties["name"].jsdoc.as_ref().unwrap();
310
+ assert_eq!(name_jsdoc["minLength"], "2");
311
+ assert_eq!(name_jsdoc["maxLength"], "100");
312
+
313
+ let updated_jsdoc = user.properties["updatedAt"].jsdoc.as_ref().unwrap();
314
+ assert_eq!(updated_jsdoc["default"], "now()");
315
+ assert_eq!(updated_jsdoc["onUpdate"], "now()");
316
+ }
317
+
318
+ #[test]
319
+ fn test_extract_exported_interface() {
320
+ let source = r#"
321
+ export interface Post {
322
+ id: string;
323
+ title: string;
324
+ }
325
+ "#;
326
+ let parsed = parse_typescript("test.ts", source).unwrap();
327
+ let types = extract_types(&parsed.module, &parsed.comments);
328
+
329
+ assert!(types.contains_key("Post"));
330
+ assert_eq!(types["Post"].properties.len(), 2);
331
+ }
332
+
333
+ #[test]
334
+ fn test_extract_complex_types() {
335
+ let source = r#"
336
+ interface Complex {
337
+ tags: string[];
338
+ metadata: Record<string, unknown>;
339
+ status: "active" | "inactive";
340
+ nested?: Array<string>;
341
+ }
342
+ "#;
343
+ let parsed = parse_typescript("test.ts", source).unwrap();
344
+ let types = extract_types(&parsed.module, &parsed.comments);
345
+
346
+ let complex = &types["Complex"];
347
+ assert_eq!(complex.properties["tags"].type_str, "string[]");
348
+ assert_eq!(complex.properties["metadata"].type_str, "Record<string, unknown>");
349
+ assert_eq!(complex.properties["status"].type_str, "\"active\" | \"inactive\"");
350
+ }
351
+
352
+ #[test]
353
+ fn test_extract_multiple_interfaces() {
354
+ let source = r#"
355
+ interface User {
356
+ id: string;
357
+ }
358
+ interface Post {
359
+ id: string;
360
+ authorId: string;
361
+ }
362
+ "#;
363
+ let parsed = parse_typescript("test.ts", source).unwrap();
364
+ let types = extract_types(&parsed.module, &parsed.comments);
365
+
366
+ assert_eq!(types.len(), 2);
367
+ assert!(types.contains_key("User"));
368
+ assert!(types.contains_key("Post"));
369
+ }
370
+ }