prax_cli/commands/
validate.rs

1//! `prax validate` command - Validate Prax schema file.
2
3use crate::cli::ValidateArgs;
4use crate::config::SCHEMA_FILE_NAME;
5use crate::error::{CliError, CliResult};
6use crate::output::{self, success, warn};
7
8/// Run the validate command
9pub async fn run(args: ValidateArgs) -> CliResult<()> {
10    output::header("Validate Schema");
11
12    let cwd = std::env::current_dir()?;
13    let schema_path = args.schema.unwrap_or_else(|| cwd.join(SCHEMA_FILE_NAME));
14
15    if !schema_path.exists() {
16        return Err(
17            CliError::Config(format!("Schema file not found: {}", schema_path.display())).into(),
18        );
19    }
20
21    output::kv("Schema", &schema_path.display().to_string());
22    output::newline();
23
24    // Parse schema
25    output::step(1, 3, "Parsing schema...");
26    let schema_content = std::fs::read_to_string(&schema_path)?;
27    let schema = parse_schema(&schema_content)?;
28
29    // Validate schema
30    output::step(2, 3, "Running validation checks...");
31    let validation_result = validate_schema(&schema);
32
33    // Check config
34    output::step(3, 3, "Checking configuration...");
35    let config_warnings = check_config(&schema);
36
37    output::newline();
38
39    // Report results
40    match validation_result {
41        Ok(()) => {
42            if config_warnings.is_empty() {
43                success("Schema is valid!");
44            } else {
45                success("Schema is valid with warnings:");
46                output::newline();
47                for warning in &config_warnings {
48                    warn(warning);
49                }
50            }
51        }
52        Err(errors) => {
53            output::error("Schema validation failed!");
54            output::newline();
55            output::section("Errors");
56            for error in &errors {
57                output::list_item(&format!("❌ {}", error));
58            }
59            if !config_warnings.is_empty() {
60                output::newline();
61                output::section("Warnings");
62                for warning in &config_warnings {
63                    warn(warning);
64                }
65            }
66            return Err(
67                CliError::Validation(format!("Found {} validation errors", errors.len())).into(),
68            );
69        }
70    }
71
72    output::newline();
73
74    // Print schema summary
75    output::section("Schema Summary");
76    output::kv("Models", &schema.models.len().to_string());
77    output::kv("Enums", &schema.enums.len().to_string());
78    output::kv("Views", &schema.views.len().to_string());
79    output::kv("Composites", &schema.types.len().to_string());
80
81    // Count fields and relations
82    let total_fields: usize = schema.models.values().map(|m| m.fields.len()).sum();
83    let relations: usize = schema
84        .models
85        .values()
86        .flat_map(|m| m.fields.values())
87        .filter(|f| f.is_relation())
88        .count();
89
90    output::kv("Total Fields", &total_fields.to_string());
91    output::kv("Relations", &relations.to_string());
92
93    Ok(())
94}
95
96fn parse_schema(content: &str) -> CliResult<prax_schema::Schema> {
97    prax_schema::parse_schema(content).map_err(|e| CliError::Schema(format!("Syntax error: {}", e)))
98}
99
100fn validate_schema(schema: &prax_schema::ast::Schema) -> Result<(), Vec<String>> {
101    let mut errors = Vec::new();
102
103    // Check for models
104    if schema.models.is_empty() {
105        errors.push("Schema must define at least one model".to_string());
106    }
107
108    // Validate each model
109    for model in schema.models.values() {
110        // Check for @id field
111        let has_id = model.fields.values().any(|f| f.is_id());
112        if !has_id {
113            errors.push(format!(
114                "Model '{}' must have a field with @id attribute",
115                model.name()
116            ));
117        }
118
119        // Check for duplicate field names (handled by IndexMap, but good to verify)
120        let mut field_names = std::collections::HashSet::new();
121        for field in model.fields.values() {
122            if !field_names.insert(field.name()) {
123                errors.push(format!(
124                    "Duplicate field '{}' in model '{}'",
125                    field.name(),
126                    model.name()
127                ));
128            }
129        }
130
131        // Validate relations
132        for field in model.fields.values() {
133            if field.is_relation() {
134                validate_relation(field, model, schema, &mut errors);
135            }
136        }
137    }
138
139    // Validate enums
140    for enum_def in schema.enums.values() {
141        if enum_def.variants.is_empty() {
142            errors.push(format!(
143                "Enum '{}' must have at least one variant",
144                enum_def.name()
145            ));
146        }
147
148        // Check for duplicate variants
149        let mut variant_names = std::collections::HashSet::new();
150        for variant in &enum_def.variants {
151            if !variant_names.insert(variant.name()) {
152                errors.push(format!(
153                    "Duplicate variant '{}' in enum '{}'",
154                    variant.name(),
155                    enum_def.name()
156                ));
157            }
158        }
159    }
160
161    // Check for duplicate model/enum names
162    let mut type_names = std::collections::HashSet::new();
163    for model in schema.models.values() {
164        if !type_names.insert(model.name()) {
165            errors.push(format!("Duplicate type name '{}'", model.name()));
166        }
167    }
168    for enum_def in schema.enums.values() {
169        if !type_names.insert(enum_def.name()) {
170            errors.push(format!("Duplicate type name '{}'", enum_def.name()));
171        }
172    }
173
174    if errors.is_empty() {
175        Ok(())
176    } else {
177        Err(errors)
178    }
179}
180
181fn validate_relation(
182    field: &prax_schema::ast::Field,
183    model: &prax_schema::ast::Model,
184    schema: &prax_schema::ast::Schema,
185    errors: &mut Vec<String>,
186) {
187    use prax_schema::ast::FieldType;
188
189    // Get the relation target type
190    let target_type = match &field.field_type {
191        FieldType::Model(name) => name.as_str(),
192        _ => return,
193    };
194
195    // Check if target model exists
196    let target_model = schema.models.get(target_type);
197    if target_model.is_none() {
198        errors.push(format!(
199            "Relation '{}' in model '{}' references unknown model '{}'",
200            field.name(),
201            model.name(),
202            target_type
203        ));
204        return;
205    }
206
207    // Validate @relation attribute if present
208    if let Some(relation_attr) = field.get_attribute("relation") {
209        // Check fields argument
210        if let Some(fields_arg) = relation_attr
211            .args
212            .iter()
213            .find(|a| a.name.as_ref().map(|n| n.as_str()) == Some("fields"))
214        {
215            if let Some(fields_str) = fields_arg.value.as_string() {
216                let field_names: Vec<&str> = fields_str.split(',').map(|s| s.trim()).collect();
217                for field_name in &field_names {
218                    if !model.fields.contains_key(*field_name) {
219                        errors.push(format!(
220                            "Relation '{}' in model '{}' references unknown field '{}'",
221                            field.name(),
222                            model.name(),
223                            field_name
224                        ));
225                    }
226                }
227            }
228        }
229
230        // Check references argument
231        if let Some(refs_arg) = relation_attr
232            .args
233            .iter()
234            .find(|a| a.name.as_ref().map(|n| n.as_str()) == Some("references"))
235        {
236            if let Some(refs_str) = refs_arg.value.as_string() {
237                let ref_names: Vec<&str> = refs_str.split(',').map(|s| s.trim()).collect();
238                let target = target_model.unwrap();
239                for ref_name in &ref_names {
240                    if !target.fields.contains_key(*ref_name) {
241                        errors.push(format!(
242                            "Relation '{}' in model '{}' references unknown field '{}' in model '{}'",
243                            field.name(),
244                            model.name(),
245                            ref_name,
246                            target_type
247                        ));
248                    }
249                }
250            }
251        }
252    }
253}
254
255fn check_config(schema: &prax_schema::ast::Schema) -> Vec<String> {
256    let mut warnings = Vec::new();
257
258    // Check for common issues
259    for model in schema.models.values() {
260        // Warn about missing timestamps
261        let has_created_at = model.fields.values().any(|f| {
262            let name_lower = f.name().to_lowercase();
263            name_lower == "createdat" || name_lower == "created_at"
264        });
265        let has_updated_at = model.fields.values().any(|f| {
266            let name_lower = f.name().to_lowercase();
267            name_lower == "updatedat" || name_lower == "updated_at"
268        });
269
270        if !has_created_at && !has_updated_at {
271            warnings.push(format!(
272                "Model '{}' has no timestamp fields (createdAt/updatedAt)",
273                model.name()
274            ));
275        }
276    }
277
278    warnings
279}