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