prax_cli/commands/
validate.rs1use crate::cli::ValidateArgs;
4use crate::config::SCHEMA_FILE_NAME;
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_NAME));
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 let relations: usize = schema
84 .models
85 .values()
86 .flat_map(|m| m.fields.values())
87 .filter(|f| f.is_relation())
88 .count();
89
90 output::kv("Total Fields", &total_fields.to_string());
91 output::kv("Relations", &relations.to_string());
92
93 Ok(())
94}
95
96fn parse_schema(content: &str) -> CliResult<prax_schema::Schema> {
97 prax_schema::parse_schema(content).map_err(|e| CliError::Schema(format!("Syntax error: {}", e)))
98}
99
100fn validate_schema(schema: &prax_schema::ast::Schema) -> Result<(), Vec<String>> {
101 let mut errors = Vec::new();
102
103 if schema.models.is_empty() {
105 errors.push("Schema must define at least one model".to_string());
106 }
107
108 for model in schema.models.values() {
110 let has_id = model.fields.values().any(|f| f.is_id());
112 if !has_id {
113 errors.push(format!(
114 "Model '{}' must have a field with @id attribute",
115 model.name()
116 ));
117 }
118
119 let mut field_names = std::collections::HashSet::new();
121 for field in model.fields.values() {
122 if !field_names.insert(field.name()) {
123 errors.push(format!(
124 "Duplicate field '{}' in model '{}'",
125 field.name(),
126 model.name()
127 ));
128 }
129 }
130
131 for field in model.fields.values() {
133 if field.is_relation() {
134 validate_relation(field, model, schema, &mut errors);
135 }
136 }
137 }
138
139 for enum_def in schema.enums.values() {
141 if enum_def.variants.is_empty() {
142 errors.push(format!(
143 "Enum '{}' must have at least one variant",
144 enum_def.name()
145 ));
146 }
147
148 let mut variant_names = std::collections::HashSet::new();
150 for variant in &enum_def.variants {
151 if !variant_names.insert(variant.name()) {
152 errors.push(format!(
153 "Duplicate variant '{}' in enum '{}'",
154 variant.name(),
155 enum_def.name()
156 ));
157 }
158 }
159 }
160
161 let mut type_names = std::collections::HashSet::new();
163 for model in schema.models.values() {
164 if !type_names.insert(model.name()) {
165 errors.push(format!("Duplicate type name '{}'", model.name()));
166 }
167 }
168 for enum_def in schema.enums.values() {
169 if !type_names.insert(enum_def.name()) {
170 errors.push(format!("Duplicate type name '{}'", enum_def.name()));
171 }
172 }
173
174 if errors.is_empty() {
175 Ok(())
176 } else {
177 Err(errors)
178 }
179}
180
181fn validate_relation(
182 field: &prax_schema::ast::Field,
183 model: &prax_schema::ast::Model,
184 schema: &prax_schema::ast::Schema,
185 errors: &mut Vec<String>,
186) {
187 use prax_schema::ast::FieldType;
188
189 let target_type = match &field.field_type {
191 FieldType::Model(name) => name.as_str(),
192 _ => return,
193 };
194
195 let target_model = schema.models.get(target_type);
197 if target_model.is_none() {
198 errors.push(format!(
199 "Relation '{}' in model '{}' references unknown model '{}'",
200 field.name(),
201 model.name(),
202 target_type
203 ));
204 return;
205 }
206
207 if let Some(relation_attr) = field.get_attribute("relation") {
209 if let Some(fields_arg) = relation_attr
211 .args
212 .iter()
213 .find(|a| a.name.as_ref().map(|n| n.as_str()) == Some("fields"))
214 {
215 if let Some(fields_str) = fields_arg.value.as_string() {
216 let field_names: Vec<&str> = fields_str.split(',').map(|s| s.trim()).collect();
217 for field_name in &field_names {
218 if !model.fields.contains_key(*field_name) {
219 errors.push(format!(
220 "Relation '{}' in model '{}' references unknown field '{}'",
221 field.name(),
222 model.name(),
223 field_name
224 ));
225 }
226 }
227 }
228 }
229
230 if let Some(refs_arg) = relation_attr
232 .args
233 .iter()
234 .find(|a| a.name.as_ref().map(|n| n.as_str()) == Some("references"))
235 {
236 if let Some(refs_str) = refs_arg.value.as_string() {
237 let ref_names: Vec<&str> = refs_str.split(',').map(|s| s.trim()).collect();
238 let target = target_model.unwrap();
239 for ref_name in &ref_names {
240 if !target.fields.contains_key(*ref_name) {
241 errors.push(format!(
242 "Relation '{}' in model '{}' references unknown field '{}' in model '{}'",
243 field.name(),
244 model.name(),
245 ref_name,
246 target_type
247 ));
248 }
249 }
250 }
251 }
252 }
253}
254
255fn check_config(schema: &prax_schema::ast::Schema) -> Vec<String> {
256 let mut warnings = Vec::new();
257
258 for model in schema.models.values() {
260 let has_created_at = model.fields.values().any(|f| {
262 let name_lower = f.name().to_lowercase();
263 name_lower == "createdat" || name_lower == "created_at"
264 });
265 let has_updated_at = model.fields.values().any(|f| {
266 let name_lower = f.name().to_lowercase();
267 name_lower == "updatedat" || name_lower == "updated_at"
268 });
269
270 if !has_created_at && !has_updated_at {
271 warnings.push(format!(
272 "Model '{}' has no timestamp fields (createdAt/updatedAt)",
273 model.name()
274 ));
275 }
276 }
277
278 warnings
279}