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 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 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 output::step(2, 3, "Running validation checks...");
32 let validation_result = validate_schema(&schema);
33
34 output::step(3, 3, "Checking configuration...");
36 let config_warnings = check_config(&schema);
37
38 output::newline();
39
40 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 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 let total_fields: usize = schema.models.values().map(|m| m.fields.len()).sum();
85
86 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 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 prax_schema::validate_schema(content).map_err(|e| {
113 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 if schema.models.is_empty() {
128 errors.push("Schema must define at least one model".to_string());
129 }
130
131 for model in schema.models.values() {
133 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 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 for field in model.fields.values() {
156 if field.is_relation() {
157 validate_relation(field, model, schema, &mut errors);
158 }
159 }
160 }
161
162 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 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 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 let target_type = match &field.field_type {
214 FieldType::Model(name) => name.as_str(),
215 _ => return,
216 };
217
218 if schema.enums.contains_key(target_type) {
220 return;
221 }
222
223 if schema.types.contains_key(target_type) {
225 return;
226 }
227
228 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 if let Some(relation_attr) = field.get_attribute("relation") {
242 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 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 for model in schema.models.values() {
291 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}