Skip to main content

prax_cli/commands/
validate.rs

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