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(
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
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 parse_schema(content: &str) -> CliResult<prax_schema::Schema> {
108    // Use validate_schema to ensure field types are properly resolved
109    // (e.g., FieldType::Model -> FieldType::Enum for enum references)
110    prax_schema::validate_schema(content).map_err(|e| {
111        // `e.to_string()` returns only the top-level `#[error("...")]` template;
112        // the useful detail (which model, which field, the inner message from
113        // `SyntaxError`, or the per-error list inside `ValidationFailed`) is on
114        // sub-fields and #[related]. Format via miette so the report shows the
115        // full diagnostic chain instead of a generic "syntax error in schema".
116        let report = miette::Report::from(e).with_source_code(content.to_string());
117        CliError::Schema(format!("{report:?}"))
118    })
119}
120
121fn validate_schema(schema: &prax_schema::ast::Schema) -> Result<(), Vec<String>> {
122    let mut errors = Vec::new();
123
124    // Check for models
125    if schema.models.is_empty() {
126        errors.push("Schema must define at least one model".to_string());
127    }
128
129    // Validate each model
130    for model in schema.models.values() {
131        // Check for @id field
132        let has_id = model.fields.values().any(|f| f.is_id());
133        if !has_id {
134            errors.push(format!(
135                "Model '{}' must have a field with @id attribute",
136                model.name()
137            ));
138        }
139
140        // Check for duplicate field names (handled by IndexMap, but good to verify)
141        let mut field_names = std::collections::HashSet::new();
142        for field in model.fields.values() {
143            if !field_names.insert(field.name()) {
144                errors.push(format!(
145                    "Duplicate field '{}' in model '{}'",
146                    field.name(),
147                    model.name()
148                ));
149            }
150        }
151
152        // Validate relations
153        for field in model.fields.values() {
154            if field.is_relation() {
155                validate_relation(field, model, schema, &mut errors);
156            }
157        }
158    }
159
160    // Validate enums
161    for enum_def in schema.enums.values() {
162        if enum_def.variants.is_empty() {
163            errors.push(format!(
164                "Enum '{}' must have at least one variant",
165                enum_def.name()
166            ));
167        }
168
169        // Check for duplicate variants
170        let mut variant_names = std::collections::HashSet::new();
171        for variant in &enum_def.variants {
172            if !variant_names.insert(variant.name()) {
173                errors.push(format!(
174                    "Duplicate variant '{}' in enum '{}'",
175                    variant.name(),
176                    enum_def.name()
177                ));
178            }
179        }
180    }
181
182    // Check for duplicate model/enum names
183    let mut type_names = std::collections::HashSet::new();
184    for model in schema.models.values() {
185        if !type_names.insert(model.name()) {
186            errors.push(format!("Duplicate type name '{}'", model.name()));
187        }
188    }
189    for enum_def in schema.enums.values() {
190        if !type_names.insert(enum_def.name()) {
191            errors.push(format!("Duplicate type name '{}'", enum_def.name()));
192        }
193    }
194
195    if errors.is_empty() {
196        Ok(())
197    } else {
198        Err(errors)
199    }
200}
201
202fn validate_relation(
203    field: &prax_schema::ast::Field,
204    model: &prax_schema::ast::Model,
205    schema: &prax_schema::ast::Schema,
206    errors: &mut Vec<String>,
207) {
208    use prax_schema::ast::FieldType;
209
210    // Get the relation target type
211    let target_type = match &field.field_type {
212        FieldType::Model(name) => name.as_str(),
213        _ => return,
214    };
215
216    // Skip if this is actually an enum reference (parser treats non-scalar as Model initially)
217    if schema.enums.contains_key(target_type) {
218        return;
219    }
220
221    // Skip if this is a composite type reference
222    if schema.types.contains_key(target_type) {
223        return;
224    }
225
226    // Check if target model exists
227    let target_model = schema.models.get(target_type);
228    if target_model.is_none() {
229        errors.push(format!(
230            "Relation '{}' in model '{}' references unknown model '{}'",
231            field.name(),
232            model.name(),
233            target_type
234        ));
235        return;
236    }
237
238    // Validate @relation attribute if present
239    if let Some(relation_attr) = field.get_attribute("relation") {
240        // Check fields argument
241        if let Some(fields_arg) = relation_attr
242            .args
243            .iter()
244            .find(|a| a.name.as_ref().map(|n| n.as_str()) == Some("fields"))
245        {
246            if let Some(fields_str) = fields_arg.value.as_string() {
247                let field_names: Vec<&str> = fields_str.split(',').map(|s| s.trim()).collect();
248                for field_name in &field_names {
249                    if !model.fields.contains_key(*field_name) {
250                        errors.push(format!(
251                            "Relation '{}' in model '{}' references unknown field '{}'",
252                            field.name(),
253                            model.name(),
254                            field_name
255                        ));
256                    }
257                }
258            }
259        }
260
261        // Check references argument
262        if let Some(refs_arg) = relation_attr
263            .args
264            .iter()
265            .find(|a| a.name.as_ref().map(|n| n.as_str()) == Some("references"))
266        {
267            if let Some(refs_str) = refs_arg.value.as_string() {
268                let ref_names: Vec<&str> = refs_str.split(',').map(|s| s.trim()).collect();
269                let target = target_model.unwrap();
270                for ref_name in &ref_names {
271                    if !target.fields.contains_key(*ref_name) {
272                        errors.push(format!(
273                            "Relation '{}' in model '{}' references unknown field '{}' in model '{}'",
274                            field.name(),
275                            model.name(),
276                            ref_name,
277                            target_type
278                        ));
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}