Skip to main content

ferro_cli/commands/
validate_contracts.rs

1//! validate:contracts command - Validate Inertia frontend/backend prop contracts
2//!
3//! Compares Rust InertiaProps structs with TypeScript interfaces to detect:
4//! - Missing fields in either direction
5//! - Type mismatches between Rust and TypeScript
6//! - Nullability mismatches (Option vs required)
7
8use console::style;
9use regex::Regex;
10use serde::Serialize;
11use std::collections::{HashMap, HashSet};
12use std::fs;
13use std::path::Path;
14
15/// Result of contract validation
16#[derive(Debug, Serialize)]
17pub struct ContractValidationResult {
18    /// ISO 8601 timestamp of when validation ran
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub timestamp: Option<String>,
21    /// Ferro CLI version
22    #[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/// Validation result for a single route
34#[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/// Information about props from either Rust or TypeScript
45#[derive(Debug, Serialize, Clone)]
46pub struct PropsInfo {
47    pub name: String,
48    pub fields: Vec<PropField>,
49    pub source_file: String,
50}
51
52/// A single field in props
53#[derive(Debug, Serialize, Clone)]
54pub struct PropField {
55    pub name: String,
56    pub field_type: String,
57    pub optional: bool,
58    /// Nested fields for complex types (objects/structs)
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub nested: Option<Vec<PropField>>,
61}
62
63/// Validation status for a route
64#[derive(Debug, Serialize, Clone, PartialEq, Eq)]
65#[serde(rename_all = "snake_case")]
66pub enum ValidationStatus {
67    Passed,
68    Failed,
69    Skipped,
70}
71
72/// A mismatch between frontend and backend
73#[derive(Debug, Serialize)]
74pub struct Mismatch {
75    pub kind: MismatchKind,
76    pub field: String,
77    pub details: String,
78}
79
80/// Type of mismatch
81#[derive(Debug, Serialize, Clone, PartialEq, Eq)]
82#[serde(rename_all = "snake_case")]
83#[allow(dead_code)] // TypeMismatch reserved for type comparison
84pub enum MismatchKind {
85    MissingInBackend,
86    MissingInFrontend,
87    TypeMismatch,
88    NullabilityMismatch,
89    StructureMismatch,
90}
91
92/// Execute contract validation
93pub 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    // Parse routes and their handlers
103    let routes = parse_routes_with_components(project_path)?;
104
105    for (route, handler, component) in &routes {
106        // Apply filter if provided
107        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    // Generate summary
128    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
156/// Parse routes file to extract route/handler/component mappings
157fn 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    // Pattern to match route definitions
170    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        // Try to find the component this handler renders
186        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
194/// Find the Inertia component that a handler renders
195fn 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    // Find the inertia_response! call in the handler
215    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    // Fallback: search for any inertia_response! after the function definition
225    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
236/// Validate a single route
237fn validate_route(
238    project_path: &Path,
239    route: &str,
240    handler: &str,
241    component: &str,
242) -> RouteValidation {
243    // Extract Rust props from handler
244    let rust_props = extract_rust_props(project_path, handler);
245
246    // Extract TypeScript props from component
247    let typescript_props = extract_typescript_props(project_path, component);
248
249    // If we can't find either, skip validation
250    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    // Compare fields
265    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
283/// Extract Rust props from a handler
284fn 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    // Find the handler function and extract what it returns
305    let func_start = content.find(&format!("fn {function_name}"))?;
306    let after_func = &content[func_start..];
307
308    // Look for the Props struct being used in inertia_response!
309    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    // Try to find the Props struct definition
319    if let Some(name) = &props_name {
320        // Search in the same file or common props locations
321        if let Some(props) = find_props_struct(&content, name, &file_path) {
322            return Some(props);
323        }
324
325        // Search in props module
326        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    // Fallback: extract inline struct fields from inertia_response!
337    extract_inline_props(after_func, &file_path)
338}
339
340/// Find a props struct definition in source code
341fn 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
359/// Extract inline props from handler code
360fn 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
382/// Parse Rust struct fields
383fn 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        // Skip if it looks like a derive attribute or comment
398        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
415/// Parse inline field names from struct instantiation
416fn 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
436/// Extract TypeScript props from a component file
437fn 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    // Strategy 1: Look for imported Props type usage in function signature
449    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        // Also try to find the interface definition
461        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        // Check imported types from inertia-props.ts
472        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    // Strategy 2: Look for interface definition in the file
489    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
504/// Parse destructured props from function signature
505fn 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
537/// Find interface fields in content
538fn 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
551/// Find imported props from types files
552fn 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
582/// Parse TypeScript interface fields with nested structure support
583fn parse_typescript_interface_fields(fields_str: &str) -> Vec<PropField> {
584    parse_typescript_fields_recursive(fields_str)
585}
586
587/// Recursively parse TypeScript fields, handling nested inline objects
588fn 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(&current_field) {
606                    fields.push(field);
607                }
608                current_field.clear();
609            }
610            '\n' if brace_depth == 0 && !current_field.trim().is_empty() => {
611                // Handle newline-terminated fields (common in TS)
612                if current_field.contains(':') {
613                    if let Some(field) = parse_single_typescript_field(&current_field) {
614                        fields.push(field);
615                    }
616                    current_field.clear();
617                }
618            }
619            _ => {
620                current_field.push(c);
621            }
622        }
623    }
624
625    // Handle last field if exists
626    if !current_field.trim().is_empty() {
627        if let Some(field) = parse_single_typescript_field(&current_field) {
628            fields.push(field);
629        }
630    }
631
632    fields
633}
634
635/// Parse a single TypeScript field declaration
636fn 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    // Pattern: name?: { nested } or name?: Type
643    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    // Check if the type is an inline object (starts with { and ends with })
651    let (field_type, nested) = if type_part.starts_with('{') && type_part.ends_with('}') {
652        // Extract nested object fields
653        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
672/// Compare Rust and TypeScript props
673fn compare_props(rust: &PropsInfo, ts: &PropsInfo) -> Vec<Mismatch> {
674    compare_fields(&rust.fields, &ts.fields, "")
675}
676
677/// Recursively compare fields between Rust and TypeScript
678fn 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    // Build a map of camelCase -> original rust field names for reverse lookup
685    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    // Check for fields in TypeScript but missing in Rust
691    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        // Check if the TS field exists in Rust (direct match or snake_case version)
699        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            // Check structural mismatch: TS has nested but Rust doesn't
707            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                // Recursively compare nested structures
727                mismatches.extend(compare_fields(rust_nested, ts_nested, &field_path));
728            }
729
730            // Check nullability mismatch
731            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    // Check for fields in Rust but not used in TypeScript
751    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
775/// Convert snake_case to camelCase
776fn 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
794/// Print validation results to console
795fn 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    // Print header
802    println!();
803    println!("{}", style("Contract Validation Results").cyan().bold());
804    println!("{}", style("=".repeat(50)).dim());
805    println!();
806
807    // Print each validation
808    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        // Print mismatches for failed validations
823        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    // Print summary
841    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
865/// Main entry point for the validate:contracts command
866pub fn run(filter: Option<String>, json: bool) {
867    let project_path = Path::new(".");
868
869    // Validate Ferro project
870    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    // Check for routes.rs
880    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                // Add metadata for JSON output
894                result.timestamp = Some(chrono::Utc::now().to_rfc3339());
895                result.ferro_version = Some(env!("CARGO_PKG_VERSION").to_string());
896
897                // Output JSON for programmatic use
898                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                // Human-readable output
911                print_results(&result);
912            }
913
914            // Exit with error code if any contracts failed
915            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        // Should match snake_case to camelCase - no mismatches
1111        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        // TypeScript expects nested structure
1163        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        // Rust sends flat structure (no nesting info)
1180        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        // Should detect structure mismatch
1194        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        // Both have matching nested structure
1202        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        // No structural mismatches when both have matching nested fields
1237        assert!(mismatches.is_empty());
1238    }
1239
1240    #[test]
1241    fn test_compare_nested_field_missing() {
1242        // Both have nested but TS expects field that Rust doesn't have
1243        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        // Should detect missing field in nested structure
1286        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        // Should have 2 mismatches: missing_in_rust in backend, extra_field in frontend
1503        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}