prax_cli/commands/
validate.rs1use crate::cli::ValidateArgs;
4use crate::config::SCHEMA_FILE_PATH;
5use crate::error::{CliError, CliResult};
6use crate::output::{self, success, warn};
7
8pub 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 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 output::step(2, 3, "Running validation checks...");
30 let validation_result = validate_schema(schema);
31
32 output::step(3, 3, "Checking configuration...");
34 let config_warnings = check_config(schema);
35
36 output::newline();
37
38 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 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 let total_fields: usize = schema.models.values().map(|m| m.fields.len()).sum();
83
84 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 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 if schema.models.is_empty() {
112 errors.push("Schema must define at least one model".to_string());
113 }
114
115 for model in schema.models.values() {
117 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 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 for field in model.fields.values() {
140 if field.is_relation() {
141 validate_relation(field, model, schema, &mut errors);
142 }
143 }
144 }
145
146 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 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 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 let target_type = match &field.field_type {
198 FieldType::Model(name) => name.as_str(),
199 _ => return,
200 };
201
202 if schema.enums.contains_key(target_type) {
204 return;
205 }
206
207 if schema.types.contains_key(target_type) {
209 return;
210 }
211
212 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 if let Some(relation_attr) = field.get_attribute("relation") {
226 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 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 for model in schema.models.values() {
275 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}