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)
113 .map_err(|e| CliError::Schema(format!("Syntax error: {}", e)))
114}
115
116fn validate_schema(schema: &prax_schema::ast::Schema) -> Result<(), Vec<String>> {
117 let mut errors = Vec::new();
118
119 if schema.models.is_empty() {
121 errors.push("Schema must define at least one model".to_string());
122 }
123
124 for model in schema.models.values() {
126 let has_id = model.fields.values().any(|f| f.is_id());
128 if !has_id {
129 errors.push(format!(
130 "Model '{}' must have a field with @id attribute",
131 model.name()
132 ));
133 }
134
135 let mut field_names = std::collections::HashSet::new();
137 for field in model.fields.values() {
138 if !field_names.insert(field.name()) {
139 errors.push(format!(
140 "Duplicate field '{}' in model '{}'",
141 field.name(),
142 model.name()
143 ));
144 }
145 }
146
147 for field in model.fields.values() {
149 if field.is_relation() {
150 validate_relation(field, model, schema, &mut errors);
151 }
152 }
153 }
154
155 for enum_def in schema.enums.values() {
157 if enum_def.variants.is_empty() {
158 errors.push(format!(
159 "Enum '{}' must have at least one variant",
160 enum_def.name()
161 ));
162 }
163
164 let mut variant_names = std::collections::HashSet::new();
166 for variant in &enum_def.variants {
167 if !variant_names.insert(variant.name()) {
168 errors.push(format!(
169 "Duplicate variant '{}' in enum '{}'",
170 variant.name(),
171 enum_def.name()
172 ));
173 }
174 }
175 }
176
177 let mut type_names = std::collections::HashSet::new();
179 for model in schema.models.values() {
180 if !type_names.insert(model.name()) {
181 errors.push(format!("Duplicate type name '{}'", model.name()));
182 }
183 }
184 for enum_def in schema.enums.values() {
185 if !type_names.insert(enum_def.name()) {
186 errors.push(format!("Duplicate type name '{}'", enum_def.name()));
187 }
188 }
189
190 if errors.is_empty() {
191 Ok(())
192 } else {
193 Err(errors)
194 }
195}
196
197fn validate_relation(
198 field: &prax_schema::ast::Field,
199 model: &prax_schema::ast::Model,
200 schema: &prax_schema::ast::Schema,
201 errors: &mut Vec<String>,
202) {
203 use prax_schema::ast::FieldType;
204
205 let target_type = match &field.field_type {
207 FieldType::Model(name) => name.as_str(),
208 _ => return,
209 };
210
211 if schema.enums.contains_key(target_type) {
213 return;
214 }
215
216 if schema.types.contains_key(target_type) {
218 return;
219 }
220
221 let target_model = schema.models.get(target_type);
223 if target_model.is_none() {
224 errors.push(format!(
225 "Relation '{}' in model '{}' references unknown model '{}'",
226 field.name(),
227 model.name(),
228 target_type
229 ));
230 return;
231 }
232
233 if let Some(relation_attr) = field.get_attribute("relation") {
235 if let Some(fields_arg) = relation_attr
237 .args
238 .iter()
239 .find(|a| a.name.as_ref().map(|n| n.as_str()) == Some("fields"))
240 && let Some(fields_str) = fields_arg.value.as_string()
241 {
242 let field_names: Vec<&str> = fields_str.split(',').map(|s| s.trim()).collect();
243 for field_name in &field_names {
244 if !model.fields.contains_key(*field_name) {
245 errors.push(format!(
246 "Relation '{}' in model '{}' references unknown field '{}'",
247 field.name(),
248 model.name(),
249 field_name
250 ));
251 }
252 }
253 }
254
255 if let Some(refs_arg) = relation_attr
257 .args
258 .iter()
259 .find(|a| a.name.as_ref().map(|n| n.as_str()) == Some("references"))
260 && let Some(refs_str) = refs_arg.value.as_string()
261 {
262 let ref_names: Vec<&str> = refs_str.split(',').map(|s| s.trim()).collect();
263 let target = target_model.unwrap();
264 for ref_name in &ref_names {
265 if !target.fields.contains_key(*ref_name) {
266 errors.push(format!(
267 "Relation '{}' in model '{}' references unknown field '{}' in model '{}'",
268 field.name(),
269 model.name(),
270 ref_name,
271 target_type
272 ));
273 }
274 }
275 }
276 }
277}
278
279fn check_config(schema: &prax_schema::ast::Schema) -> Vec<String> {
280 let mut warnings = Vec::new();
281
282 for model in schema.models.values() {
284 let has_created_at = model.fields.values().any(|f| {
286 let name_lower = f.name().to_lowercase();
287 name_lower == "createdat" || name_lower == "created_at"
288 });
289 let has_updated_at = model.fields.values().any(|f| {
290 let name_lower = f.name().to_lowercase();
291 name_lower == "updatedat" || name_lower == "updated_at"
292 });
293
294 if !has_created_at && !has_updated_at {
295 warnings.push(format!(
296 "Model '{}' has no timestamp fields (createdAt/updatedAt)",
297 model.name()
298 ));
299 }
300 }
301
302 warnings
303}