1use console::style;
9use regex::Regex;
10use serde::Serialize;
11use std::collections::{HashMap, HashSet};
12use std::fs;
13use std::path::Path;
14
15#[derive(Debug, Serialize)]
17pub struct ContractValidationResult {
18 #[serde(skip_serializing_if = "Option::is_none")]
20 pub timestamp: Option<String>,
21 #[serde(skip_serializing_if = "Option::is_none")]
23 pub ferro_version: Option<String>,
24 pub total_routes: usize,
25 pub validated: usize,
26 pub passed: usize,
27 pub failed: usize,
28 pub skipped: usize,
29 pub validations: Vec<RouteValidation>,
30 pub summary: Vec<String>,
31}
32
33#[derive(Debug, Serialize)]
35pub struct RouteValidation {
36 pub route: String,
37 pub component: String,
38 pub status: ValidationStatus,
39 pub rust_props: Option<PropsInfo>,
40 pub typescript_props: Option<PropsInfo>,
41 pub mismatches: Vec<Mismatch>,
42}
43
44#[derive(Debug, Serialize, Clone)]
46pub struct PropsInfo {
47 pub name: String,
48 pub fields: Vec<PropField>,
49 pub source_file: String,
50}
51
52#[derive(Debug, Serialize, Clone)]
54pub struct PropField {
55 pub name: String,
56 pub field_type: String,
57 pub optional: bool,
58 #[serde(skip_serializing_if = "Option::is_none")]
60 pub nested: Option<Vec<PropField>>,
61}
62
63#[derive(Debug, Serialize, Clone, PartialEq, Eq)]
65#[serde(rename_all = "snake_case")]
66pub enum ValidationStatus {
67 Passed,
68 Failed,
69 Skipped,
70}
71
72#[derive(Debug, Serialize)]
74pub struct Mismatch {
75 pub kind: MismatchKind,
76 pub field: String,
77 pub details: String,
78}
79
80#[derive(Debug, Serialize, Clone, PartialEq, Eq)]
82#[serde(rename_all = "snake_case")]
83#[allow(dead_code)] pub enum MismatchKind {
85 MissingInBackend,
86 MissingInFrontend,
87 TypeMismatch,
88 NullabilityMismatch,
89 StructureMismatch,
90}
91
92pub fn execute(
94 project_path: &Path,
95 route_filter: Option<&str>,
96) -> Result<ContractValidationResult, String> {
97 let mut validations = Vec::new();
98 let mut passed = 0;
99 let mut failed = 0;
100 let mut skipped = 0;
101
102 let routes = parse_routes_with_components(project_path)?;
104
105 for (route, handler, component) in &routes {
106 if let Some(filter) = route_filter {
108 if !route.contains(filter) && !component.contains(filter) {
109 continue;
110 }
111 }
112
113 let validation = validate_route(project_path, route, handler, component);
114
115 match validation.status {
116 ValidationStatus::Passed => passed += 1,
117 ValidationStatus::Failed => failed += 1,
118 ValidationStatus::Skipped => skipped += 1,
119 }
120
121 validations.push(validation);
122 }
123
124 let total_routes = validations.len();
125 let validated = passed + failed;
126
127 let mut summary = Vec::new();
129 if failed > 0 {
130 summary.push(format!(
131 "{failed} contract(s) have mismatches that need attention"
132 ));
133 }
134 if passed > 0 {
135 summary.push(format!("{passed} contract(s) validated successfully"));
136 }
137 if skipped > 0 {
138 summary.push(format!(
139 "{skipped} route(s) skipped (no props or component found)"
140 ));
141 }
142
143 Ok(ContractValidationResult {
144 timestamp: None,
145 ferro_version: None,
146 total_routes,
147 validated,
148 passed,
149 failed,
150 skipped,
151 validations,
152 summary,
153 })
154}
155
156fn parse_routes_with_components(
158 project_path: &Path,
159) -> Result<Vec<(String, String, String)>, String> {
160 let routes_file = project_path.join("src/routes.rs");
161 if !routes_file.exists() {
162 return Err("src/routes.rs not found".to_string());
163 }
164
165 let content =
166 fs::read_to_string(&routes_file).map_err(|e| format!("Failed to read routes.rs: {e}"))?;
167 let mut routes = Vec::new();
168
169 let route_pattern = Regex::new(
171 r#"(get|post|put|patch|delete)!\s*\(\s*"([^"]+)"\s*,\s*([a-zA-Z_][a-zA-Z0-9_:]*)\s*\)"#,
172 )
173 .unwrap();
174
175 for cap in route_pattern.captures_iter(&content) {
176 let path = cap
177 .get(2)
178 .map(|m| m.as_str().to_string())
179 .unwrap_or_default();
180 let handler = cap
181 .get(3)
182 .map(|m| m.as_str().to_string())
183 .unwrap_or_default();
184
185 if let Some(component) = find_component_for_handler(project_path, &handler) {
187 routes.push((path, handler, component));
188 }
189 }
190
191 Ok(routes)
192}
193
194fn find_component_for_handler(project_path: &Path, handler: &str) -> Option<String> {
196 let parts: Vec<&str> = handler.split("::").collect();
197 if parts.len() < 2 {
198 return None;
199 }
200
201 let file_parts: Vec<&str> = parts[..parts.len() - 1].to_vec();
202 let file_path = project_path
203 .join("src")
204 .join(file_parts.join("/"))
205 .with_extension("rs");
206
207 if !file_path.exists() {
208 return None;
209 }
210
211 let content = fs::read_to_string(&file_path).ok()?;
212 let function_name = parts.last()?;
213
214 let inertia_pattern = Regex::new(&format!(
216 r#"fn\s+{function_name}\s*\([^)]*\)[^{{]*\{{[^}}]*inertia_response!\s*\(\s*"([^"]+)""#
217 ))
218 .ok()?;
219
220 if let Some(cap) = inertia_pattern.captures(&content) {
221 return cap.get(1).map(|m| m.as_str().to_string());
222 }
223
224 let func_start = content.find(&format!("fn {function_name}"))?;
226 let after_func = &content[func_start..];
227
228 let simple_pattern = Regex::new(r#"inertia_response!\s*\(\s*"([^"]+)""#).ok()?;
229 if let Some(cap) = simple_pattern.captures(after_func) {
230 return cap.get(1).map(|m| m.as_str().to_string());
231 }
232
233 None
234}
235
236fn validate_route(
238 project_path: &Path,
239 route: &str,
240 handler: &str,
241 component: &str,
242) -> RouteValidation {
243 let rust_props = extract_rust_props(project_path, handler);
245
246 let typescript_props = extract_typescript_props(project_path, component);
248
249 if rust_props.is_none() || typescript_props.is_none() {
251 return RouteValidation {
252 route: route.to_string(),
253 component: component.to_string(),
254 status: ValidationStatus::Skipped,
255 rust_props,
256 typescript_props,
257 mismatches: vec![],
258 };
259 }
260
261 let rust = rust_props.as_ref().unwrap();
262 let ts = typescript_props.as_ref().unwrap();
263
264 let mismatches = compare_props(rust, ts);
266
267 let status = if mismatches.is_empty() {
268 ValidationStatus::Passed
269 } else {
270 ValidationStatus::Failed
271 };
272
273 RouteValidation {
274 route: route.to_string(),
275 component: component.to_string(),
276 status,
277 rust_props,
278 typescript_props,
279 mismatches,
280 }
281}
282
283fn extract_rust_props(project_path: &Path, handler: &str) -> Option<PropsInfo> {
285 let parts: Vec<&str> = handler.split("::").collect();
286 if parts.len() < 2 {
287 return None;
288 }
289
290 let file_parts: Vec<&str> = parts[..parts.len() - 1].to_vec();
291 let function_name = parts.last()?;
292
293 let file_path = project_path
294 .join("src")
295 .join(file_parts.join("/"))
296 .with_extension("rs");
297
298 if !file_path.exists() {
299 return None;
300 }
301
302 let content = fs::read_to_string(&file_path).ok()?;
303
304 let func_start = content.find(&format!("fn {function_name}"))?;
306 let after_func = &content[func_start..];
307
308 let props_pattern =
310 Regex::new(r#"inertia_response!\s*\(\s*"[^"]+"\s*,\s*([A-Z][a-zA-Z0-9]*)\s*\{"#).ok()?;
311
312 let props_name = if let Some(cap) = props_pattern.captures(after_func) {
313 cap.get(1).map(|m| m.as_str().to_string())
314 } else {
315 None
316 };
317
318 if let Some(name) = &props_name {
320 if let Some(props) = find_props_struct(&content, name, &file_path) {
322 return Some(props);
323 }
324
325 let props_file = project_path.join("src/props.rs");
327 if props_file.exists() {
328 if let Ok(props_content) = fs::read_to_string(&props_file) {
329 if let Some(props) = find_props_struct(&props_content, name, &props_file) {
330 return Some(props);
331 }
332 }
333 }
334 }
335
336 extract_inline_props(after_func, &file_path)
338}
339
340fn find_props_struct(content: &str, name: &str, source_file: &Path) -> Option<PropsInfo> {
342 let struct_pattern = Regex::new(&format!(
343 r#"(?:#\[derive\([^\)]*\)\]\s*)*struct\s+{name}\s*\{{\s*([^}}]+)\}}"#
344 ))
345 .ok()?;
346
347 let cap = struct_pattern.captures(content)?;
348 let fields_str = cap.get(1)?.as_str();
349
350 let fields = parse_rust_fields(fields_str);
351
352 Some(PropsInfo {
353 name: name.to_string(),
354 fields,
355 source_file: source_file.to_string_lossy().to_string(),
356 })
357}
358
359fn extract_inline_props(handler_code: &str, source_file: &Path) -> Option<PropsInfo> {
361 let inline_pattern = Regex::new(r#"([A-Z][a-zA-Z0-9]*)\s*\{\s*([^}]+)\}"#).ok()?;
362
363 for cap in inline_pattern.captures_iter(handler_code) {
364 let name = cap.get(1)?.as_str();
365 if name.ends_with("Props") || name.ends_with("Detail") || name.ends_with("Summary") {
366 let fields_str = cap.get(2)?.as_str();
367 let fields = parse_inline_fields(fields_str);
368
369 if !fields.is_empty() {
370 return Some(PropsInfo {
371 name: name.to_string(),
372 fields,
373 source_file: source_file.to_string_lossy().to_string(),
374 });
375 }
376 }
377 }
378
379 None
380}
381
382fn parse_rust_fields(fields_str: &str) -> Vec<PropField> {
384 let mut fields = Vec::new();
385 let field_pattern = Regex::new(r#"(?:pub\s+)?(\w+)\s*:\s*([^,\n]+)"#).unwrap();
386
387 for cap in field_pattern.captures_iter(fields_str) {
388 let name = cap
389 .get(1)
390 .map(|m| m.as_str().to_string())
391 .unwrap_or_default();
392 let field_type = cap
393 .get(2)
394 .map(|m| m.as_str().trim().to_string())
395 .unwrap_or_default();
396
397 if name.starts_with('#') || name.starts_with('/') {
399 continue;
400 }
401
402 let optional = field_type.starts_with("Option<");
403
404 fields.push(PropField {
405 name,
406 field_type,
407 optional,
408 nested: None,
409 });
410 }
411
412 fields
413}
414
415fn parse_inline_fields(fields_str: &str) -> Vec<PropField> {
417 let mut fields = Vec::new();
418 let field_pattern = Regex::new(r#"(\w+)\s*:"#).unwrap();
419
420 for cap in field_pattern.captures_iter(fields_str) {
421 let name = cap
422 .get(1)
423 .map(|m| m.as_str().to_string())
424 .unwrap_or_default();
425 fields.push(PropField {
426 name,
427 field_type: "unknown".to_string(),
428 optional: false,
429 nested: None,
430 });
431 }
432
433 fields
434}
435
436fn extract_typescript_props(project_path: &Path, component: &str) -> Option<PropsInfo> {
438 let component_path = project_path
439 .join("frontend/src/pages")
440 .join(format!("{component}.tsx"));
441
442 if !component_path.exists() {
443 return None;
444 }
445
446 let content = fs::read_to_string(&component_path).ok()?;
447
448 let func_pattern = Regex::new(
450 r#"(?:export\s+default\s+)?function\s+\w+\s*\(\s*\{\s*([^}]+)\s*\}\s*:\s*(\w+)"#,
451 )
452 .ok()?;
453
454 if let Some(cap) = func_pattern.captures(&content) {
455 let destructured = cap.get(1)?.as_str();
456 let props_type = cap.get(2)?.as_str();
457
458 let fields = parse_destructured_props(destructured);
459
460 let mut all_fields = fields;
462 if let Some(interface_fields) = find_interface_fields(&content, props_type) {
463 let existing_names: HashSet<_> = all_fields.iter().map(|f| f.name.clone()).collect();
464 for field in interface_fields {
465 if !existing_names.contains(&field.name) {
466 all_fields.push(field);
467 }
468 }
469 }
470
471 if let Some(imported_fields) = find_imported_props(project_path, &content, props_type) {
473 let existing_names: HashSet<_> = all_fields.iter().map(|f| f.name.clone()).collect();
474 for field in imported_fields {
475 if !existing_names.contains(&field.name) {
476 all_fields.push(field);
477 }
478 }
479 }
480
481 return Some(PropsInfo {
482 name: props_type.to_string(),
483 fields: all_fields,
484 source_file: component_path.to_string_lossy().to_string(),
485 });
486 }
487
488 let interface_pattern = Regex::new(r#"interface\s+(\w*Props\w*)\s*\{([^}]+)\}"#).ok()?;
490 if let Some(cap) = interface_pattern.captures(&content) {
491 let name = cap.get(1)?.as_str();
492 let fields_str = cap.get(2)?.as_str();
493
494 return Some(PropsInfo {
495 name: name.to_string(),
496 fields: parse_typescript_interface_fields(fields_str),
497 source_file: component_path.to_string_lossy().to_string(),
498 });
499 }
500
501 None
502}
503
504fn parse_destructured_props(destructured: &str) -> Vec<PropField> {
506 let mut fields = Vec::new();
507
508 for part in destructured.split(',') {
509 let part = part.trim();
510 if part.is_empty() {
511 continue;
512 }
513
514 let name = part
515 .split(':')
516 .next()
517 .unwrap_or(part)
518 .split('=')
519 .next()
520 .unwrap_or(part)
521 .trim()
522 .to_string();
523
524 if !name.is_empty() && !name.starts_with("...") {
525 fields.push(PropField {
526 name,
527 field_type: "unknown".to_string(),
528 optional: part.contains('='),
529 nested: None,
530 });
531 }
532 }
533
534 fields
535}
536
537fn find_interface_fields(content: &str, props_type: &str) -> Option<Vec<PropField>> {
539 let pattern = Regex::new(&format!(
540 r#"interface\s+{}\s*(?:extends\s+[^{{]+)?\{{\s*([^}}]+)\}}"#,
541 regex::escape(props_type)
542 ))
543 .ok()?;
544
545 let cap = pattern.captures(content)?;
546 let fields_str = cap.get(1)?.as_str();
547
548 Some(parse_typescript_interface_fields(fields_str))
549}
550
551fn find_imported_props(
553 project_path: &Path,
554 content: &str,
555 props_type: &str,
556) -> Option<Vec<PropField>> {
557 let pattern_str = format!(
558 r#"import\s*\{{[^}}]*\b{}\b[^}}]*\}}\s*from\s*['"]([^'"]+)['"]"#,
559 regex::escape(props_type)
560 );
561 let import_pattern = Regex::new(&pattern_str).ok()?;
562
563 let cap = import_pattern.captures(content)?;
564 let import_path = cap.get(1)?.as_str();
565
566 let types_file = if import_path.contains("inertia-props") {
567 project_path.join("frontend/src/types/inertia-props.ts")
568 } else if import_path.contains("shared") {
569 project_path.join("frontend/src/types/shared.ts")
570 } else {
571 return None;
572 };
573
574 if !types_file.exists() {
575 return None;
576 }
577
578 let types_content = fs::read_to_string(&types_file).ok()?;
579 find_interface_fields(&types_content, props_type)
580}
581
582fn parse_typescript_interface_fields(fields_str: &str) -> Vec<PropField> {
584 parse_typescript_fields_recursive(fields_str)
585}
586
587fn parse_typescript_fields_recursive(fields_str: &str) -> Vec<PropField> {
589 let mut fields = Vec::new();
590 let chars = fields_str.chars().peekable();
591 let mut current_field = String::new();
592 let mut brace_depth = 0;
593
594 for c in chars {
595 match c {
596 '{' => {
597 brace_depth += 1;
598 current_field.push(c);
599 }
600 '}' => {
601 brace_depth -= 1;
602 current_field.push(c);
603 }
604 ';' | ',' if brace_depth == 0 => {
605 if let Some(field) = parse_single_typescript_field(¤t_field) {
606 fields.push(field);
607 }
608 current_field.clear();
609 }
610 '\n' if brace_depth == 0 && !current_field.trim().is_empty() => {
611 if current_field.contains(':') {
613 if let Some(field) = parse_single_typescript_field(¤t_field) {
614 fields.push(field);
615 }
616 current_field.clear();
617 }
618 }
619 _ => {
620 current_field.push(c);
621 }
622 }
623 }
624
625 if !current_field.trim().is_empty() {
627 if let Some(field) = parse_single_typescript_field(¤t_field) {
628 fields.push(field);
629 }
630 }
631
632 fields
633}
634
635fn parse_single_typescript_field(field_str: &str) -> Option<PropField> {
637 let field_str = field_str.trim();
638 if field_str.is_empty() {
639 return None;
640 }
641
642 let field_pattern = Regex::new(r#"^(\w+)(\?)?:\s*(.+)$"#).ok()?;
644
645 let cap = field_pattern.captures(field_str)?;
646 let name = cap.get(1)?.as_str().to_string();
647 let optional = cap.get(2).is_some();
648 let type_part = cap.get(3)?.as_str().trim();
649
650 let (field_type, nested) = if type_part.starts_with('{') && type_part.ends_with('}') {
652 let inner = &type_part[1..type_part.len() - 1];
654 let nested_fields = parse_typescript_fields_recursive(inner);
655 if nested_fields.is_empty() {
656 ("object".to_string(), None)
657 } else {
658 ("object".to_string(), Some(nested_fields))
659 }
660 } else {
661 (type_part.to_string(), None)
662 };
663
664 Some(PropField {
665 name,
666 field_type,
667 optional,
668 nested,
669 })
670}
671
672fn compare_props(rust: &PropsInfo, ts: &PropsInfo) -> Vec<Mismatch> {
674 compare_fields(&rust.fields, &ts.fields, "")
675}
676
677fn compare_fields(rust_fields: &[PropField], ts_fields: &[PropField], path: &str) -> Vec<Mismatch> {
679 let mut mismatches = Vec::new();
680
681 let rust_map: HashMap<_, _> = rust_fields.iter().map(|f| (f.name.clone(), f)).collect();
682 let ts_map: HashMap<_, _> = ts_fields.iter().map(|f| (f.name.clone(), f)).collect();
683
684 let rust_camel_to_snake: HashMap<String, String> = rust_map
686 .keys()
687 .map(|name| (to_camel_case(name), name.clone()))
688 .collect();
689
690 for (name, ts_field) in &ts_map {
692 let field_path = if path.is_empty() {
693 name.clone()
694 } else {
695 format!("{path}.{name}")
696 };
697
698 let rust_field = rust_map.get(name).or_else(|| {
700 rust_camel_to_snake
701 .get(name)
702 .and_then(|sn| rust_map.get(sn))
703 });
704
705 if let Some(rf) = rust_field {
706 if ts_field.nested.is_some() && rf.nested.is_none() {
708 mismatches.push(Mismatch {
709 kind: MismatchKind::StructureMismatch,
710 field: field_path.clone(),
711 details: format!(
712 "Frontend expects '{}' to have nested properties but backend sends flat type '{}'",
713 name, rf.field_type
714 ),
715 });
716 } else if ts_field.nested.is_none() && rf.nested.is_some() {
717 mismatches.push(Mismatch {
718 kind: MismatchKind::StructureMismatch,
719 field: field_path.clone(),
720 details: format!(
721 "Backend sends '{}' with nested structure but frontend expects flat type '{}'",
722 name, ts_field.field_type
723 ),
724 });
725 } else if let (Some(ts_nested), Some(rust_nested)) = (&ts_field.nested, &rf.nested) {
726 mismatches.extend(compare_fields(rust_nested, ts_nested, &field_path));
728 }
729
730 if rf.optional && !ts_field.optional && !ts_field.field_type.contains("null") {
732 mismatches.push(Mismatch {
733 kind: MismatchKind::NullabilityMismatch,
734 field: field_path,
735 details: format!(
736 "Backend sends Option<{}> but frontend expects non-nullable {}",
737 rf.field_type, ts_field.field_type
738 ),
739 });
740 }
741 } else if !ts_field.optional {
742 mismatches.push(Mismatch {
743 kind: MismatchKind::MissingInBackend,
744 field: field_path,
745 details: format!("Frontend expects '{name}' but backend doesn't send it"),
746 });
747 }
748 }
749
750 for name in rust_map.keys() {
752 let field_path = if path.is_empty() {
753 name.clone()
754 } else {
755 format!("{path}.{name}")
756 };
757
758 if !ts_map.contains_key(name) {
759 let camel_name = to_camel_case(name);
760 if !ts_map.contains_key(&camel_name) {
761 mismatches.push(Mismatch {
762 kind: MismatchKind::MissingInFrontend,
763 field: field_path,
764 details: format!(
765 "Backend sends '{name}' but frontend doesn't use it (might be intentional)"
766 ),
767 });
768 }
769 }
770 }
771
772 mismatches
773}
774
775fn to_camel_case(s: &str) -> String {
777 let mut result = String::with_capacity(s.len());
778 let mut capitalize_next = false;
779
780 for c in s.chars() {
781 if c == '_' {
782 capitalize_next = true;
783 } else if capitalize_next {
784 result.extend(c.to_uppercase());
785 capitalize_next = false;
786 } else {
787 result.push(c);
788 }
789 }
790
791 result
792}
793
794fn print_results(result: &ContractValidationResult) {
796 if result.validations.is_empty() {
797 println!("{}", style("No Inertia routes found to validate.").yellow());
798 return;
799 }
800
801 println!();
803 println!("{}", style("Contract Validation Results").cyan().bold());
804 println!("{}", style("=".repeat(50)).dim());
805 println!();
806
807 for validation in &result.validations {
809 let status_icon = match validation.status {
810 ValidationStatus::Passed => style("PASS").green(),
811 ValidationStatus::Failed => style("FAIL").red(),
812 ValidationStatus::Skipped => style("SKIP").yellow(),
813 };
814
815 println!(
816 " {} {} -> {}",
817 status_icon,
818 style(&validation.route).bold(),
819 style(&validation.component).dim()
820 );
821
822 for mismatch in &validation.mismatches {
824 let kind_label = match mismatch.kind {
825 MismatchKind::MissingInBackend => "missing in backend",
826 MismatchKind::MissingInFrontend => "missing in frontend",
827 MismatchKind::TypeMismatch => "type mismatch",
828 MismatchKind::NullabilityMismatch => "nullability mismatch",
829 MismatchKind::StructureMismatch => "structure mismatch",
830 };
831 println!(
832 " {} {} - {}",
833 style("->").red(),
834 style(format!("[{kind_label}]")).yellow(),
835 mismatch.details
836 );
837 }
838 }
839
840 println!();
842 println!("{}", style("-".repeat(50)).dim());
843 println!();
844
845 println!(
846 " {} {} validated, {} passed, {} failed, {} skipped",
847 style("Total:").bold(),
848 result.validated,
849 style(result.passed).green(),
850 if result.failed > 0 {
851 style(result.failed).red()
852 } else {
853 style(result.failed).green()
854 },
855 style(result.skipped).yellow()
856 );
857
858 for summary_line in &result.summary {
859 println!(" {} {}", style("->").cyan(), summary_line);
860 }
861
862 println!();
863}
864
865pub fn run(filter: Option<String>, json: bool) {
867 let project_path = Path::new(".");
868
869 let cargo_toml = project_path.join("Cargo.toml");
871 if !cargo_toml.exists() {
872 eprintln!(
873 "{} Not a Ferro project (no Cargo.toml found)",
874 style("Error:").red().bold()
875 );
876 std::process::exit(1);
877 }
878
879 let routes_rs = project_path.join("src/routes.rs");
881 if !routes_rs.exists() {
882 eprintln!("{} No src/routes.rs found", style("Error:").red().bold());
883 std::process::exit(1);
884 }
885
886 if !json {
887 println!("{}", style("Validating Inertia contracts...").cyan());
888 }
889
890 match execute(project_path, filter.as_deref()) {
891 Ok(mut result) => {
892 if json {
893 result.timestamp = Some(chrono::Utc::now().to_rfc3339());
895 result.ferro_version = Some(env!("CARGO_PKG_VERSION").to_string());
896
897 match serde_json::to_string_pretty(&result) {
899 Ok(json_output) => println!("{json_output}"),
900 Err(e) => {
901 eprintln!(
902 "{} Failed to serialize results: {}",
903 style("Error:").red().bold(),
904 e
905 );
906 std::process::exit(1);
907 }
908 }
909 } else {
910 print_results(&result);
912 }
913
914 if result.failed > 0 {
916 std::process::exit(1);
917 }
918 }
919 Err(e) => {
920 eprintln!("{} {}", style("Error:").red().bold(), e);
921 std::process::exit(1);
922 }
923 }
924}
925
926#[cfg(test)]
927mod tests {
928 use super::*;
929
930 #[test]
931 fn test_to_camel_case() {
932 assert_eq!(to_camel_case("created_at"), "createdAt");
933 assert_eq!(to_camel_case("user_id"), "userId");
934 assert_eq!(to_camel_case("some_long_name"), "someLongName");
935 assert_eq!(to_camel_case("name"), "name");
936 }
937
938 #[test]
939 fn test_parse_rust_fields() {
940 let fields_str = r#"
941 pub id: i64,
942 pub name: String,
943 pub email: Option<String>,
944 "#;
945
946 let fields = parse_rust_fields(fields_str);
947
948 assert_eq!(fields.len(), 3);
949 assert_eq!(fields[0].name, "id");
950 assert!(!fields[0].optional);
951 assert_eq!(fields[1].name, "name");
952 assert!(!fields[1].optional);
953 assert_eq!(fields[2].name, "email");
954 assert!(fields[2].optional);
955 }
956
957 #[test]
958 fn test_parse_typescript_interface_fields() {
959 let fields_str = r#"
960 id: number;
961 name: string;
962 email?: string;
963 "#;
964
965 let fields = parse_typescript_interface_fields(fields_str);
966
967 assert_eq!(fields.len(), 3);
968 assert_eq!(fields[0].name, "id");
969 assert!(!fields[0].optional);
970 assert_eq!(fields[1].name, "name");
971 assert!(!fields[1].optional);
972 assert_eq!(fields[2].name, "email");
973 assert!(fields[2].optional);
974 }
975
976 #[test]
977 fn test_compare_props_missing_in_backend() {
978 let rust = PropsInfo {
979 name: "TestProps".to_string(),
980 fields: vec![PropField {
981 name: "id".to_string(),
982 field_type: "i64".to_string(),
983 optional: false,
984 nested: None,
985 }],
986 source_file: "test.rs".to_string(),
987 };
988
989 let ts = PropsInfo {
990 name: "TestProps".to_string(),
991 fields: vec![
992 PropField {
993 name: "id".to_string(),
994 field_type: "number".to_string(),
995 optional: false,
996 nested: None,
997 },
998 PropField {
999 name: "name".to_string(),
1000 field_type: "string".to_string(),
1001 optional: false,
1002 nested: None,
1003 },
1004 ],
1005 source_file: "test.tsx".to_string(),
1006 };
1007
1008 let mismatches = compare_props(&rust, &ts);
1009
1010 assert_eq!(mismatches.len(), 1);
1011 assert_eq!(mismatches[0].kind, MismatchKind::MissingInBackend);
1012 assert_eq!(mismatches[0].field, "name");
1013 }
1014
1015 #[test]
1016 fn test_compare_props_missing_in_frontend() {
1017 let rust = PropsInfo {
1018 name: "TestProps".to_string(),
1019 fields: vec![
1020 PropField {
1021 name: "id".to_string(),
1022 field_type: "i64".to_string(),
1023 optional: false,
1024 nested: None,
1025 },
1026 PropField {
1027 name: "extra".to_string(),
1028 field_type: "String".to_string(),
1029 optional: false,
1030 nested: None,
1031 },
1032 ],
1033 source_file: "test.rs".to_string(),
1034 };
1035
1036 let ts = PropsInfo {
1037 name: "TestProps".to_string(),
1038 fields: vec![PropField {
1039 name: "id".to_string(),
1040 field_type: "number".to_string(),
1041 optional: false,
1042 nested: None,
1043 }],
1044 source_file: "test.tsx".to_string(),
1045 };
1046
1047 let mismatches = compare_props(&rust, &ts);
1048
1049 assert_eq!(mismatches.len(), 1);
1050 assert_eq!(mismatches[0].kind, MismatchKind::MissingInFrontend);
1051 assert_eq!(mismatches[0].field, "extra");
1052 }
1053
1054 #[test]
1055 fn test_compare_props_nullability_mismatch() {
1056 let rust = PropsInfo {
1057 name: "TestProps".to_string(),
1058 fields: vec![PropField {
1059 name: "value".to_string(),
1060 field_type: "Option<String>".to_string(),
1061 optional: true,
1062 nested: None,
1063 }],
1064 source_file: "test.rs".to_string(),
1065 };
1066
1067 let ts = PropsInfo {
1068 name: "TestProps".to_string(),
1069 fields: vec![PropField {
1070 name: "value".to_string(),
1071 field_type: "string".to_string(),
1072 optional: false,
1073 nested: None,
1074 }],
1075 source_file: "test.tsx".to_string(),
1076 };
1077
1078 let mismatches = compare_props(&rust, &ts);
1079
1080 assert_eq!(mismatches.len(), 1);
1081 assert_eq!(mismatches[0].kind, MismatchKind::NullabilityMismatch);
1082 }
1083
1084 #[test]
1085 fn test_compare_props_camel_case_matching() {
1086 let rust = PropsInfo {
1087 name: "TestProps".to_string(),
1088 fields: vec![PropField {
1089 name: "created_at".to_string(),
1090 field_type: "String".to_string(),
1091 optional: false,
1092 nested: None,
1093 }],
1094 source_file: "test.rs".to_string(),
1095 };
1096
1097 let ts = PropsInfo {
1098 name: "TestProps".to_string(),
1099 fields: vec![PropField {
1100 name: "createdAt".to_string(),
1101 field_type: "string".to_string(),
1102 optional: false,
1103 nested: None,
1104 }],
1105 source_file: "test.tsx".to_string(),
1106 };
1107
1108 let mismatches = compare_props(&rust, &ts);
1109
1110 assert!(mismatches.is_empty());
1112 }
1113
1114 #[test]
1115 fn test_parse_destructured_props() {
1116 let destructured = "id, name, email = ''";
1117 let fields = parse_destructured_props(destructured);
1118
1119 assert_eq!(fields.len(), 3);
1120 assert_eq!(fields[0].name, "id");
1121 assert!(!fields[0].optional);
1122 assert_eq!(fields[1].name, "name");
1123 assert!(!fields[1].optional);
1124 assert_eq!(fields[2].name, "email");
1125 assert!(fields[2].optional);
1126 }
1127
1128 #[test]
1129 fn test_parse_inline_fields() {
1130 let fields_str = "id: user.id, name: user.name, active: true";
1131 let fields = parse_inline_fields(fields_str);
1132
1133 assert_eq!(fields.len(), 3);
1134 assert_eq!(fields[0].name, "id");
1135 assert_eq!(fields[1].name, "name");
1136 assert_eq!(fields[2].name, "active");
1137 }
1138
1139 #[test]
1140 fn test_parse_typescript_nested_fields() {
1141 let fields_str = r#"
1142 id: number;
1143 application: { id: number; name: string };
1144 "#;
1145
1146 let fields = parse_typescript_interface_fields(fields_str);
1147
1148 assert_eq!(fields.len(), 2);
1149 assert_eq!(fields[0].name, "id");
1150 assert!(fields[0].nested.is_none());
1151
1152 assert_eq!(fields[1].name, "application");
1153 assert!(fields[1].nested.is_some());
1154 let nested = fields[1].nested.as_ref().unwrap();
1155 assert_eq!(nested.len(), 2);
1156 assert_eq!(nested[0].name, "id");
1157 assert_eq!(nested[1].name, "name");
1158 }
1159
1160 #[test]
1161 fn test_compare_structure_mismatch_ts_nested_rust_flat() {
1162 let ts = PropsInfo {
1164 name: "ShowProps".to_string(),
1165 fields: vec![PropField {
1166 name: "application".to_string(),
1167 field_type: "object".to_string(),
1168 optional: false,
1169 nested: Some(vec![PropField {
1170 name: "animal".to_string(),
1171 field_type: "Animal".to_string(),
1172 optional: false,
1173 nested: None,
1174 }]),
1175 }],
1176 source_file: "test.tsx".to_string(),
1177 };
1178
1179 let rust = PropsInfo {
1181 name: "ShowProps".to_string(),
1182 fields: vec![PropField {
1183 name: "application".to_string(),
1184 field_type: "ApplicationDetail".to_string(),
1185 optional: false,
1186 nested: None,
1187 }],
1188 source_file: "test.rs".to_string(),
1189 };
1190
1191 let mismatches = compare_props(&rust, &ts);
1192
1193 assert_eq!(mismatches.len(), 1);
1195 assert_eq!(mismatches[0].kind, MismatchKind::StructureMismatch);
1196 assert_eq!(mismatches[0].field, "application");
1197 }
1198
1199 #[test]
1200 fn test_compare_structure_matching_nested() {
1201 let ts = PropsInfo {
1203 name: "ShowProps".to_string(),
1204 fields: vec![PropField {
1205 name: "application".to_string(),
1206 field_type: "object".to_string(),
1207 optional: false,
1208 nested: Some(vec![PropField {
1209 name: "id".to_string(),
1210 field_type: "number".to_string(),
1211 optional: false,
1212 nested: None,
1213 }]),
1214 }],
1215 source_file: "test.tsx".to_string(),
1216 };
1217
1218 let rust = PropsInfo {
1219 name: "ShowProps".to_string(),
1220 fields: vec![PropField {
1221 name: "application".to_string(),
1222 field_type: "Application".to_string(),
1223 optional: false,
1224 nested: Some(vec![PropField {
1225 name: "id".to_string(),
1226 field_type: "i64".to_string(),
1227 optional: false,
1228 nested: None,
1229 }]),
1230 }],
1231 source_file: "test.rs".to_string(),
1232 };
1233
1234 let mismatches = compare_props(&rust, &ts);
1235
1236 assert!(mismatches.is_empty());
1238 }
1239
1240 #[test]
1241 fn test_compare_nested_field_missing() {
1242 let ts = PropsInfo {
1244 name: "ShowProps".to_string(),
1245 fields: vec![PropField {
1246 name: "application".to_string(),
1247 field_type: "object".to_string(),
1248 optional: false,
1249 nested: Some(vec![
1250 PropField {
1251 name: "id".to_string(),
1252 field_type: "number".to_string(),
1253 optional: false,
1254 nested: None,
1255 },
1256 PropField {
1257 name: "extra".to_string(),
1258 field_type: "string".to_string(),
1259 optional: false,
1260 nested: None,
1261 },
1262 ]),
1263 }],
1264 source_file: "test.tsx".to_string(),
1265 };
1266
1267 let rust = PropsInfo {
1268 name: "ShowProps".to_string(),
1269 fields: vec![PropField {
1270 name: "application".to_string(),
1271 field_type: "Application".to_string(),
1272 optional: false,
1273 nested: Some(vec![PropField {
1274 name: "id".to_string(),
1275 field_type: "i64".to_string(),
1276 optional: false,
1277 nested: None,
1278 }]),
1279 }],
1280 source_file: "test.rs".to_string(),
1281 };
1282
1283 let mismatches = compare_props(&rust, &ts);
1284
1285 assert_eq!(mismatches.len(), 1);
1287 assert_eq!(mismatches[0].kind, MismatchKind::MissingInBackend);
1288 assert_eq!(mismatches[0].field, "application.extra");
1289 }
1290
1291 #[test]
1292 fn test_parse_typescript_deeply_nested() {
1293 let fields_str = r#"
1294 user: { profile: { avatar: string } };
1295 "#;
1296
1297 let fields = parse_typescript_interface_fields(fields_str);
1298
1299 assert_eq!(fields.len(), 1);
1300 assert_eq!(fields[0].name, "user");
1301 assert!(fields[0].nested.is_some());
1302
1303 let nested = fields[0].nested.as_ref().unwrap();
1304 assert_eq!(nested.len(), 1);
1305 assert_eq!(nested[0].name, "profile");
1306 assert!(nested[0].nested.is_some());
1307
1308 let deeply_nested = nested[0].nested.as_ref().unwrap();
1309 assert_eq!(deeply_nested.len(), 1);
1310 assert_eq!(deeply_nested[0].name, "avatar");
1311 }
1312
1313 #[test]
1314 fn test_parse_typescript_optional_nested() {
1315 let fields_str = r#"
1316 data?: { id: number };
1317 "#;
1318
1319 let fields = parse_typescript_interface_fields(fields_str);
1320
1321 assert_eq!(fields.len(), 1);
1322 assert_eq!(fields[0].name, "data");
1323 assert!(fields[0].optional);
1324 assert!(fields[0].nested.is_some());
1325 }
1326
1327 #[test]
1328 fn test_compare_deeply_nested_structure() {
1329 let ts = PropsInfo {
1330 name: "Props".to_string(),
1331 fields: vec![PropField {
1332 name: "user".to_string(),
1333 field_type: "object".to_string(),
1334 optional: false,
1335 nested: Some(vec![PropField {
1336 name: "profile".to_string(),
1337 field_type: "object".to_string(),
1338 optional: false,
1339 nested: Some(vec![PropField {
1340 name: "name".to_string(),
1341 field_type: "string".to_string(),
1342 optional: false,
1343 nested: None,
1344 }]),
1345 }]),
1346 }],
1347 source_file: "test.tsx".to_string(),
1348 };
1349
1350 let rust = PropsInfo {
1351 name: "Props".to_string(),
1352 fields: vec![PropField {
1353 name: "user".to_string(),
1354 field_type: "User".to_string(),
1355 optional: false,
1356 nested: Some(vec![PropField {
1357 name: "profile".to_string(),
1358 field_type: "Profile".to_string(),
1359 optional: false,
1360 nested: Some(vec![PropField {
1361 name: "name".to_string(),
1362 field_type: "String".to_string(),
1363 optional: false,
1364 nested: None,
1365 }]),
1366 }]),
1367 }],
1368 source_file: "test.rs".to_string(),
1369 };
1370
1371 let mismatches = compare_props(&rust, &ts);
1372 assert!(mismatches.is_empty());
1373 }
1374
1375 #[test]
1376 fn test_compare_nested_nullability_mismatch() {
1377 let ts = PropsInfo {
1378 name: "Props".to_string(),
1379 fields: vec![PropField {
1380 name: "data".to_string(),
1381 field_type: "object".to_string(),
1382 optional: false,
1383 nested: Some(vec![PropField {
1384 name: "value".to_string(),
1385 field_type: "string".to_string(),
1386 optional: false,
1387 nested: None,
1388 }]),
1389 }],
1390 source_file: "test.tsx".to_string(),
1391 };
1392
1393 let rust = PropsInfo {
1394 name: "Props".to_string(),
1395 fields: vec![PropField {
1396 name: "data".to_string(),
1397 field_type: "Data".to_string(),
1398 optional: false,
1399 nested: Some(vec![PropField {
1400 name: "value".to_string(),
1401 field_type: "Option<String>".to_string(),
1402 optional: true,
1403 nested: None,
1404 }]),
1405 }],
1406 source_file: "test.rs".to_string(),
1407 };
1408
1409 let mismatches = compare_props(&rust, &ts);
1410
1411 assert_eq!(mismatches.len(), 1);
1412 assert_eq!(mismatches[0].kind, MismatchKind::NullabilityMismatch);
1413 assert_eq!(mismatches[0].field, "data.value");
1414 }
1415
1416 #[test]
1417 fn test_all_fields_match() {
1418 let rust = PropsInfo {
1419 name: "TestProps".to_string(),
1420 fields: vec![
1421 PropField {
1422 name: "id".to_string(),
1423 field_type: "i64".to_string(),
1424 optional: false,
1425 nested: None,
1426 },
1427 PropField {
1428 name: "name".to_string(),
1429 field_type: "String".to_string(),
1430 optional: false,
1431 nested: None,
1432 },
1433 ],
1434 source_file: "test.rs".to_string(),
1435 };
1436
1437 let ts = PropsInfo {
1438 name: "TestProps".to_string(),
1439 fields: vec![
1440 PropField {
1441 name: "id".to_string(),
1442 field_type: "number".to_string(),
1443 optional: false,
1444 nested: None,
1445 },
1446 PropField {
1447 name: "name".to_string(),
1448 field_type: "string".to_string(),
1449 optional: false,
1450 nested: None,
1451 },
1452 ],
1453 source_file: "test.tsx".to_string(),
1454 };
1455
1456 let mismatches = compare_props(&rust, &ts);
1457 assert!(mismatches.is_empty());
1458 }
1459
1460 #[test]
1461 fn test_multiple_mismatches() {
1462 let rust = PropsInfo {
1463 name: "TestProps".to_string(),
1464 fields: vec![
1465 PropField {
1466 name: "id".to_string(),
1467 field_type: "i64".to_string(),
1468 optional: false,
1469 nested: None,
1470 },
1471 PropField {
1472 name: "extra_field".to_string(),
1473 field_type: "String".to_string(),
1474 optional: false,
1475 nested: None,
1476 },
1477 ],
1478 source_file: "test.rs".to_string(),
1479 };
1480
1481 let ts = PropsInfo {
1482 name: "TestProps".to_string(),
1483 fields: vec![
1484 PropField {
1485 name: "id".to_string(),
1486 field_type: "number".to_string(),
1487 optional: false,
1488 nested: None,
1489 },
1490 PropField {
1491 name: "missing_in_rust".to_string(),
1492 field_type: "string".to_string(),
1493 optional: false,
1494 nested: None,
1495 },
1496 ],
1497 source_file: "test.tsx".to_string(),
1498 };
1499
1500 let mismatches = compare_props(&rust, &ts);
1501
1502 assert_eq!(mismatches.len(), 2);
1504
1505 let missing_backend = mismatches
1506 .iter()
1507 .find(|m| m.kind == MismatchKind::MissingInBackend);
1508 let missing_frontend = mismatches
1509 .iter()
1510 .find(|m| m.kind == MismatchKind::MissingInFrontend);
1511
1512 assert!(missing_backend.is_some());
1513 assert!(missing_frontend.is_some());
1514 assert_eq!(missing_backend.unwrap().field, "missing_in_rust");
1515 assert_eq!(missing_frontend.unwrap().field, "extra_field");
1516 }
1517}