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(
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 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 output::step(2, 3, "Running validation checks...");
31 let validation_result = validate_schema(&schema);
32
33 output::step(3, 3, "Checking configuration...");
35 let config_warnings = check_config(&schema);
36
37 output::newline();
38
39 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 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 parse_schema(content: &str) -> CliResult<prax_schema::Schema> {
108 prax_schema::validate_schema(content).map_err(|e| {
111 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 if schema.models.is_empty() {
126 errors.push("Schema must define at least one model".to_string());
127 }
128
129 for model in schema.models.values() {
131 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 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 for field in model.fields.values() {
154 if field.is_relation() {
155 validate_relation(field, model, schema, &mut errors);
156 }
157 }
158 }
159
160 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 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 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 let target_type = match &field.field_type {
212 FieldType::Model(name) => name.as_str(),
213 _ => return,
214 };
215
216 if schema.enums.contains_key(target_type) {
218 return;
219 }
220
221 if schema.types.contains_key(target_type) {
223 return;
224 }
225
226 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 if let Some(relation_attr) = field.get_attribute("relation") {
240 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 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 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}