1use serde_json::Value;
7
8use crate::{
9 compiler::ir::{IREnum, IREnumValue},
10 error::{FraiseQLError, Result},
11};
12
13#[derive(Debug)]
21pub struct EnumValidator;
22
23impl EnumValidator {
24 pub fn parse_enums(enums_value: &Value) -> Result<Vec<IREnum>> {
68 let enums_arr = enums_value.as_array().ok_or_else(|| FraiseQLError::Validation {
69 message: "enums must be an array".to_string(),
70 path: Some("schema.enums".to_string()),
71 })?;
72
73 let mut enums = Vec::new();
74 for (idx, enum_def) in enums_arr.iter().enumerate() {
75 let enum_obj = enum_def.as_object().ok_or_else(|| FraiseQLError::Validation {
76 message: format!("enum at index {} must be an object", idx),
77 path: Some(format!("schema.enums[{}]", idx)),
78 })?;
79
80 let enum_type = Self::parse_single_enum(enum_obj, idx)?;
81 enums.push(enum_type);
82 }
83
84 Ok(enums)
85 }
86
87 fn parse_single_enum(
94 enum_obj: &serde_json::Map<String, Value>,
95 index: usize,
96 ) -> Result<IREnum> {
97 let name = enum_obj
99 .get("name")
100 .and_then(|v| v.as_str())
101 .ok_or_else(|| FraiseQLError::Validation {
102 message: "enum must have a name".to_string(),
103 path: Some(format!("schema.enums[{}].name", index)),
104 })?
105 .to_string();
106
107 Self::validate_enum_name(&name)?;
109
110 let description =
112 enum_obj.get("description").and_then(|v| v.as_str()).map(|s| s.to_string());
113
114 let values_value = enum_obj.get("values").ok_or_else(|| FraiseQLError::Validation {
116 message: format!("enum '{}' must have 'values' field", name),
117 path: Some(format!("schema.enums[{}].values", index)),
118 })?;
119
120 let values = Self::parse_enum_values(values_value, &name)?;
121
122 if values.is_empty() {
124 return Err(FraiseQLError::Validation {
125 message: format!("enum '{}' must have at least one value", name),
126 path: Some(format!("schema.enums[{}].values", index)),
127 });
128 }
129
130 Ok(IREnum {
131 name,
132 values,
133 description,
134 })
135 }
136
137 fn parse_enum_values(values_value: &Value, enum_name: &str) -> Result<Vec<IREnumValue>> {
144 let values_arr = values_value.as_array().ok_or_else(|| FraiseQLError::Validation {
145 message: format!("enum '{}' values must be an array", enum_name),
146 path: Some(format!("schema.enums.{}.values", enum_name)),
147 })?;
148
149 let mut values = Vec::new();
150 let mut seen_names = std::collections::HashSet::new();
151
152 for (idx, value_def) in values_arr.iter().enumerate() {
153 let value_obj = value_def.as_object().ok_or_else(|| FraiseQLError::Validation {
154 message: format!("enum '{}' value at index {} must be an object", enum_name, idx),
155 path: Some(format!("schema.enums.{}.values[{}]", enum_name, idx)),
156 })?;
157
158 let value_name = value_obj
160 .get("name")
161 .and_then(|v| v.as_str())
162 .ok_or_else(|| FraiseQLError::Validation {
163 message: format!(
164 "enum '{}' value at index {} must have a name",
165 enum_name, idx
166 ),
167 path: Some(format!("schema.enums.{}.values[{}].name", enum_name, idx)),
168 })?
169 .to_string();
170
171 Self::validate_enum_value_name(&value_name, enum_name)?;
173
174 if !seen_names.insert(value_name.clone()) {
176 return Err(FraiseQLError::Validation {
177 message: format!("enum '{}' has duplicate value '{}'", enum_name, value_name),
178 path: Some(format!("schema.enums.{}.values", enum_name)),
179 });
180 }
181
182 let description =
184 value_obj.get("description").and_then(|v| v.as_str()).map(|s| s.to_string());
185
186 let deprecation_reason = value_obj
188 .get("deprecationReason")
189 .and_then(|v| v.as_str())
190 .map(|s| s.to_string());
191
192 values.push(IREnumValue {
193 name: value_name,
194 description,
195 deprecation_reason,
196 });
197 }
198
199 Ok(values)
200 }
201
202 fn validate_enum_name(name: &str) -> Result<()> {
206 if name.is_empty() {
207 return Err(FraiseQLError::Validation {
208 message: "enum name cannot be empty".to_string(),
209 path: Some("schema.enums.name".to_string()),
210 });
211 }
212
213 if !name
214 .chars()
215 .next()
216 .expect("name is non-empty; empty was rejected above")
217 .is_alphabetic()
218 {
219 return Err(FraiseQLError::Validation {
220 message: format!("enum name '{}' must start with a letter", name),
221 path: Some("schema.enums.name".to_string()),
222 });
223 }
224
225 if !name.chars().all(|c| c.is_alphanumeric() || c == '_') {
226 return Err(FraiseQLError::Validation {
227 message: format!(
228 "enum name '{}' contains invalid characters (use alphanumeric and underscore)",
229 name
230 ),
231 path: Some("schema.enums.name".to_string()),
232 });
233 }
234
235 Ok(())
236 }
237
238 fn validate_enum_value_name(name: &str, enum_name: &str) -> Result<()> {
242 if name.is_empty() {
243 return Err(FraiseQLError::Validation {
244 message: format!("enum '{}' value name cannot be empty", enum_name),
245 path: Some(format!("schema.enums.{}.values.name", enum_name)),
246 });
247 }
248
249 if !name.chars().all(|c| c.is_uppercase() || c.is_numeric() || c == '_') {
250 return Err(FraiseQLError::Validation {
251 message: format!(
252 "enum '{}' value '{}' should use SCREAMING_SNAKE_CASE (uppercase with underscores)",
253 enum_name, name
254 ),
255 path: Some(format!("schema.enums.{}.values.name", enum_name)),
256 });
257 }
258
259 if name.starts_with('_') {
261 return Err(FraiseQLError::Validation {
262 message: format!(
263 "enum '{}' value '{}' cannot start with underscore",
264 enum_name, name
265 ),
266 path: Some(format!("schema.enums.{}.values.name", enum_name)),
267 });
268 }
269
270 Ok(())
271 }
272}
273
274#[cfg(test)]
275mod tests {
276 #![allow(clippy::unwrap_used)] use super::*;
279
280 #[test]
281 fn test_parse_simple_enum() {
282 let json = serde_json::json!([
283 {
284 "name": "Status",
285 "values": [
286 {"name": "ACTIVE"},
287 {"name": "INACTIVE"}
288 ]
289 }
290 ]);
291
292 let enums = EnumValidator::parse_enums(&json)
293 .unwrap_or_else(|e| panic!("parse simple enum should succeed: {e}"));
294 assert_eq!(enums.len(), 1);
295 assert_eq!(enums[0].name, "Status");
296 assert_eq!(enums[0].values.len(), 2);
297 }
298
299 #[test]
300 fn test_parse_enum_with_description() {
301 let json = serde_json::json!([
302 {
303 "name": "UserStatus",
304 "description": "User account status",
305 "values": [
306 {
307 "name": "ACTIVE",
308 "description": "User is active"
309 }
310 ]
311 }
312 ]);
313
314 let enums = EnumValidator::parse_enums(&json)
315 .unwrap_or_else(|e| panic!("parse enum with description should succeed: {e}"));
316 assert_eq!(enums[0].description, Some("User account status".to_string()));
317 assert_eq!(enums[0].values[0].description, Some("User is active".to_string()));
318 }
319
320 #[test]
321 fn test_parse_enum_with_deprecation() {
322 let json = serde_json::json!([
323 {
324 "name": "Status",
325 "values": [
326 {
327 "name": "OLD_STATUS",
328 "deprecationReason": "Use NEW_STATUS instead"
329 }
330 ]
331 }
332 ]);
333
334 let enums = EnumValidator::parse_enums(&json)
335 .unwrap_or_else(|e| panic!("parse enum with deprecation should succeed: {e}"));
336 assert_eq!(
337 enums[0].values[0].deprecation_reason,
338 Some("Use NEW_STATUS instead".to_string())
339 );
340 }
341
342 #[test]
343 fn test_parse_multiple_enums() {
344 let json = serde_json::json!([
345 {
346 "name": "Status",
347 "values": [{"name": "ACTIVE"}]
348 },
349 {
350 "name": "Priority",
351 "values": [{"name": "HIGH"}, {"name": "LOW"}]
352 }
353 ]);
354
355 let enums = EnumValidator::parse_enums(&json)
356 .unwrap_or_else(|e| panic!("parse multiple enums should succeed: {e}"));
357 assert_eq!(enums.len(), 2);
358 }
359
360 #[test]
361 fn test_enum_not_array() {
362 let json = serde_json::json!({"name": "Status"});
363 let result = EnumValidator::parse_enums(&json);
364 assert!(
365 matches!(result, Err(FraiseQLError::Validation { .. })),
366 "expected Validation error for non-array enums, got: {result:?}"
367 );
368 }
369
370 #[test]
371 fn test_enum_missing_name() {
372 let json = serde_json::json!([
373 {
374 "values": [{"name": "ACTIVE"}]
375 }
376 ]);
377
378 let result = EnumValidator::parse_enums(&json);
379 assert!(
380 matches!(result, Err(FraiseQLError::Validation { .. })),
381 "expected Validation error for missing enum name, got: {result:?}"
382 );
383 }
384
385 #[test]
386 fn test_enum_missing_values() {
387 let json = serde_json::json!([
388 {
389 "name": "Status"
390 }
391 ]);
392
393 let result = EnumValidator::parse_enums(&json);
394 assert!(
395 matches!(result, Err(FraiseQLError::Validation { .. })),
396 "expected Validation error for missing values field, got: {result:?}"
397 );
398 }
399
400 #[test]
401 fn test_enum_empty_values() {
402 let json = serde_json::json!([
403 {
404 "name": "Status",
405 "values": []
406 }
407 ]);
408
409 let result = EnumValidator::parse_enums(&json);
410 assert!(
411 matches!(result, Err(FraiseQLError::Validation { .. })),
412 "expected Validation error for empty values, got: {result:?}"
413 );
414 }
415
416 #[test]
417 fn test_enum_duplicate_values() {
418 let json = serde_json::json!([
419 {
420 "name": "Status",
421 "values": [
422 {"name": "ACTIVE"},
423 {"name": "ACTIVE"}
424 ]
425 }
426 ]);
427
428 let result = EnumValidator::parse_enums(&json);
429 assert!(
430 matches!(result, Err(FraiseQLError::Validation { .. })),
431 "expected Validation error for duplicate values, got: {result:?}"
432 );
433 }
434
435 #[test]
436 fn test_enum_value_missing_name() {
437 let json = serde_json::json!([
438 {
439 "name": "Status",
440 "values": [
441 {"description": "Active status"}
442 ]
443 }
444 ]);
445
446 let result = EnumValidator::parse_enums(&json);
447 assert!(
448 matches!(result, Err(FraiseQLError::Validation { .. })),
449 "expected Validation error for missing value name, got: {result:?}"
450 );
451 }
452
453 #[test]
454 fn test_validate_enum_name_valid() {
455 EnumValidator::validate_enum_name("Status")
456 .unwrap_or_else(|e| panic!("'Status' should be valid: {e}"));
457 EnumValidator::validate_enum_name("UserStatus")
458 .unwrap_or_else(|e| panic!("'UserStatus' should be valid: {e}"));
459 EnumValidator::validate_enum_name("Status2")
460 .unwrap_or_else(|e| panic!("'Status2' should be valid: {e}"));
461 }
462
463 #[test]
464 fn test_validate_enum_name_invalid_start() {
465 let result = EnumValidator::validate_enum_name("2Status");
466 assert!(
467 matches!(result, Err(FraiseQLError::Validation { .. })),
468 "expected Validation error for name starting with digit, got: {result:?}"
469 );
470 }
471
472 #[test]
473 fn test_validate_enum_name_invalid_chars() {
474 let result1 = EnumValidator::validate_enum_name("Status-Type");
475 assert!(
476 matches!(result1, Err(FraiseQLError::Validation { .. })),
477 "expected Validation error for hyphen in name, got: {result1:?}"
478 );
479 let result2 = EnumValidator::validate_enum_name("Status Type");
480 assert!(
481 matches!(result2, Err(FraiseQLError::Validation { .. })),
482 "expected Validation error for space in name, got: {result2:?}"
483 );
484 }
485
486 #[test]
487 fn test_validate_enum_value_valid() {
488 EnumValidator::validate_enum_value_name("ACTIVE", "Status")
489 .unwrap_or_else(|e| panic!("'ACTIVE' should be valid: {e}"));
490 EnumValidator::validate_enum_value_name("ACTIVE_STATUS", "Status")
491 .unwrap_or_else(|e| panic!("'ACTIVE_STATUS' should be valid: {e}"));
492 EnumValidator::validate_enum_value_name("ACTIVE_STATUS_2", "Status")
493 .unwrap_or_else(|e| panic!("'ACTIVE_STATUS_2' should be valid: {e}"));
494 }
495
496 #[test]
497 fn test_validate_enum_value_invalid_lowercase() {
498 let result = EnumValidator::validate_enum_value_name("Active", "Status");
499 assert!(
500 matches!(result, Err(FraiseQLError::Validation { .. })),
501 "expected Validation error for lowercase value name, got: {result:?}"
502 );
503 }
504
505 #[test]
506 fn test_validate_enum_value_invalid_start_underscore() {
507 let result = EnumValidator::validate_enum_value_name("_ACTIVE", "Status");
508 assert!(
509 matches!(result, Err(FraiseQLError::Validation { .. })),
510 "expected Validation error for underscore-prefixed value, got: {result:?}"
511 );
512 }
513
514 #[test]
515 fn test_enum_name_empty() {
516 let result = EnumValidator::validate_enum_name("");
517 assert!(
518 matches!(result, Err(FraiseQLError::Validation { .. })),
519 "expected Validation error for empty enum name, got: {result:?}"
520 );
521 }
522
523 #[test]
524 fn test_parse_complex_enum_scenario() {
525 let json = serde_json::json!([
526 {
527 "name": "OrderStatus",
528 "description": "Order processing status",
529 "values": [
530 {
531 "name": "PENDING",
532 "description": "Order awaiting processing"
533 },
534 {
535 "name": "PROCESSING",
536 "description": "Order is being processed"
537 },
538 {
539 "name": "COMPLETED",
540 "description": "Order has been completed"
541 },
542 {
543 "name": "CANCELLED",
544 "description": "Order was cancelled",
545 "deprecationReason": "Use VOID instead"
546 }
547 ]
548 }
549 ]);
550
551 let enums = EnumValidator::parse_enums(&json)
552 .unwrap_or_else(|e| panic!("parse complex enum scenario should succeed: {e}"));
553 assert_eq!(enums[0].name, "OrderStatus");
554 assert_eq!(enums[0].values.len(), 4);
555 assert!(enums[0].values[3].deprecation_reason.is_some());
556 }
557
558 #[test]
559 fn test_serialization_roundtrip() {
560 let enum_val = IREnum {
561 name: "Status".to_string(),
562 values: vec![IREnumValue {
563 name: "ACTIVE".to_string(),
564 description: Some("Active status".to_string()),
565 deprecation_reason: None,
566 }],
567 description: Some("Status enum".to_string()),
568 };
569
570 let json = serde_json::to_string(&enum_val).expect("serialize should work");
571 let restored: IREnum = serde_json::from_str(&json).expect("deserialize should work");
572
573 assert_eq!(restored.name, enum_val.name);
574 assert_eq!(restored.values.len(), enum_val.values.len());
575 }
576}