1use crate::model::common::Config;
2use crate::{App, Layout, MuxBox};
3use jsonschema::JSONSchema;
4use serde_json::{Map, Value};
5use std::collections::HashSet;
6use std::fs;
7
8#[derive(Debug, Clone)]
10pub enum ValidationError {
11 InvalidFieldType {
12 field: String,
13 expected: String,
14 actual: String,
15 },
16 MissingRequiredField {
17 field: String,
18 },
19 InvalidFieldValue {
20 field: String,
21 value: String,
22 constraint: String,
23 },
24 DuplicateId {
25 id: String,
26 location: String,
27 },
28 InvalidReference {
29 field: String,
30 reference: String,
31 target_type: String,
32 },
33 SchemaStructure {
34 message: String,
35 },
36 JsonSchemaValidation {
37 field: String,
38 message: String,
39 },
40}
41
42impl std::fmt::Display for ValidationError {
43 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44 match self {
45 ValidationError::InvalidFieldType {
46 field,
47 expected,
48 actual,
49 } => {
50 write!(
51 f,
52 "Field '{}' expected type '{}' but got '{}'",
53 field, expected, actual
54 )
55 }
56 ValidationError::MissingRequiredField { field } => {
57 write!(f, "Required field '{}' is missing", field)
58 }
59 ValidationError::InvalidFieldValue {
60 field,
61 value,
62 constraint,
63 } => {
64 write!(
65 f,
66 "Field '{}' has invalid value '{}' (constraint: {})",
67 field, value, constraint
68 )
69 }
70 ValidationError::DuplicateId { id, location } => {
71 write!(f, "Duplicate ID '{}' found in {}", id, location)
72 }
73 ValidationError::InvalidReference {
74 field,
75 reference,
76 target_type,
77 } => {
78 write!(
79 f,
80 "Field '{}' references unknown {} '{}'",
81 field, target_type, reference
82 )
83 }
84 ValidationError::SchemaStructure { message } => {
85 write!(f, "Schema structure error: {}", message)
86 }
87 ValidationError::JsonSchemaValidation { field, message } => {
88 write!(
89 f,
90 "JSON Schema validation error in '{}': {}",
91 field, message
92 )
93 }
94 }
95 }
96}
97
98impl std::error::Error for ValidationError {}
99
100pub type ValidationResult = Result<(), Vec<ValidationError>>;
102
103pub struct SchemaValidator {
105 errors: Vec<ValidationError>,
106 muxbox_ids: HashSet<String>,
107 layout_ids: HashSet<String>,
108}
109
110impl SchemaValidator {
111 pub fn new() -> Self {
112 Self {
113 errors: Vec::new(),
114 muxbox_ids: HashSet::new(),
115 layout_ids: HashSet::new(),
116 }
117 }
118
119 pub fn validate_app(&mut self, app: &App) -> ValidationResult {
121 self.clear();
122
123 self.collect_ids(app);
125
126 if app.layouts.is_empty() {
128 self.add_error(ValidationError::MissingRequiredField {
129 field: "layouts".to_string(),
130 });
131 }
132
133 for (idx, layout) in app.layouts.iter().enumerate() {
135 let _ = self.validate_layout(layout, &format!("layouts[{}]", idx));
136 }
137
138 self.validate_root_layout_constraints(app);
140
141 if self.errors.is_empty() {
142 Ok(())
143 } else {
144 Err(self.errors.clone())
145 }
146 }
147
148 pub fn validate_layout(&mut self, layout: &Layout, path: &str) -> ValidationResult {
150 if layout.id.is_empty() {
152 self.add_error(ValidationError::MissingRequiredField {
153 field: format!("{}.id", path),
154 });
155 }
156
157 if let Some(muxboxes) = &layout.children {
159 for (idx, muxbox) in muxboxes.iter().enumerate() {
160 let _ = self.validate_muxbox(muxbox, &format!("{}.children[{}]", path, idx));
161 }
162 }
163
164 if self.errors.is_empty() {
165 Ok(())
166 } else {
167 Err(self.errors.clone())
168 }
169 }
170
171 pub fn validate_muxbox(&mut self, muxbox: &MuxBox, path: &str) -> ValidationResult {
173 if muxbox.id.is_empty() {
175 self.add_error(ValidationError::MissingRequiredField {
176 field: format!("{}.id", path),
177 });
178 }
179
180 self.validate_input_bounds_schema(&muxbox.position, &format!("{}.position", path));
182
183 if let Some(children) = &muxbox.children {
185 for (idx, child) in children.iter().enumerate() {
186 let _ = self.validate_muxbox(child, &format!("{}.children[{}]", path, idx));
187 }
188 }
189
190 if let Some(scripts) = &muxbox.script {
192 for (idx, script) in scripts.iter().enumerate() {
193 if script.trim().is_empty() {
194 self.add_error(ValidationError::InvalidFieldValue {
195 field: format!("{}.script[{}]", path, idx),
196 value: "empty".to_string(),
197 constraint: "script commands cannot be empty".to_string(),
198 });
199 }
200 }
201 }
202
203 if self.errors.is_empty() {
204 Ok(())
205 } else {
206 Err(self.errors.clone())
207 }
208 }
209
210 pub fn validate_config(&mut self, config: &Config) -> ValidationResult {
212 self.clear();
213
214 if config.frame_delay == 0 {
215 self.add_error(ValidationError::InvalidFieldValue {
216 field: "frame_delay".to_string(),
217 value: "0".to_string(),
218 constraint: "frame_delay must be greater than 0".to_string(),
219 });
220 }
221
222 if config.frame_delay > 1000 {
223 self.add_error(ValidationError::InvalidFieldValue {
224 field: "frame_delay".to_string(),
225 value: config.frame_delay.to_string(),
226 constraint: "frame_delay should not exceed 1000ms for usability".to_string(),
227 });
228 }
229
230 if self.errors.is_empty() {
231 Ok(())
232 } else {
233 Err(self.errors.clone())
234 }
235 }
236
237 pub fn validate_with_json_schema(
239 &mut self,
240 yaml_content: &str,
241 schema_dir: &str,
242 ) -> ValidationResult {
243 self.clear();
244
245 let yaml_value: Value = match serde_yaml::from_str(yaml_content) {
247 Ok(value) => value,
248 Err(e) => {
249 self.add_error(ValidationError::SchemaStructure {
250 message: format!("Invalid YAML syntax: {}", e),
251 });
252 return Err(self.errors.clone());
253 }
254 };
255
256 let app_schema_path = format!("{}/app_schema.json", schema_dir);
258 if let Err(e) = self.validate_against_schema_file(&yaml_value, &app_schema_path, "app") {
259 return Err(e);
260 }
261
262 if self.errors.is_empty() {
263 Ok(())
264 } else {
265 Err(self.errors.clone())
266 }
267 }
268
269 fn validate_against_schema_file(
271 &mut self,
272 value: &Value,
273 schema_path: &str,
274 field_name: &str,
275 ) -> ValidationResult {
276 let schema_content = match fs::read_to_string(schema_path) {
278 Ok(content) => content,
279 Err(e) => {
280 self.add_error(ValidationError::SchemaStructure {
281 message: format!("Failed to load schema file '{}': {}", schema_path, e),
282 });
283 return Err(self.errors.clone());
284 }
285 };
286
287 let schema_json: Value = match serde_json::from_str(&schema_content) {
289 Ok(schema) => schema,
290 Err(e) => {
291 self.add_error(ValidationError::SchemaStructure {
292 message: format!("Invalid JSON schema in '{}': {}", schema_path, e),
293 });
294 return Err(self.errors.clone());
295 }
296 };
297
298 let compiled_schema = match JSONSchema::compile(&schema_json) {
300 Ok(schema) => schema,
301 Err(e) => {
302 self.add_error(ValidationError::SchemaStructure {
303 message: format!("Failed to compile schema '{}': {}", schema_path, e),
304 });
305 return Err(self.errors.clone());
306 }
307 };
308
309 if let Err(errors) = compiled_schema.validate(value) {
311 for error in errors {
312 let error_path = if error.instance_path.to_string().is_empty() {
313 field_name.to_string()
314 } else {
315 format!("{}.{}", field_name, error.instance_path)
316 };
317
318 self.add_error(ValidationError::JsonSchemaValidation {
319 field: error_path,
320 message: error.to_string(),
321 });
322 }
323 return Err(self.errors.clone());
324 }
325
326 Ok(())
327 }
328
329 pub fn validate_json_config(&mut self, config: &Value) -> ValidationResult {
331 self.clear();
332
333 if !config.is_object() {
334 self.add_error(ValidationError::SchemaStructure {
335 message: "Configuration must be a JSON object".to_string(),
336 });
337 return Err(self.errors.clone());
338 }
339
340 let obj = config.as_object().unwrap();
341
342 if !obj.contains_key("layouts") {
344 self.add_error(ValidationError::MissingRequiredField {
345 field: "layouts".to_string(),
346 });
347 } else if !obj["layouts"].is_array() {
348 self.add_error(ValidationError::InvalidFieldType {
349 field: "layouts".to_string(),
350 expected: "array".to_string(),
351 actual: self.get_json_type(&obj["layouts"]),
352 });
353 }
354
355 if let Some(config_section) = obj.get("config") {
357 if !config_section.is_object() {
358 self.add_error(ValidationError::InvalidFieldType {
359 field: "config".to_string(),
360 expected: "object".to_string(),
361 actual: self.get_json_type(config_section),
362 });
363 } else {
364 self.validate_json_config_section(config_section.as_object().unwrap());
365 }
366 }
367
368 if self.errors.is_empty() {
369 Ok(())
370 } else {
371 Err(self.errors.clone())
372 }
373 }
374
375 fn clear(&mut self) {
377 self.errors.clear();
378 self.muxbox_ids.clear();
379 self.layout_ids.clear();
380 }
381
382 fn add_error(&mut self, error: ValidationError) {
383 self.errors.push(error);
384 }
385
386 fn collect_ids(&mut self, app: &App) {
387 for layout in &app.layouts {
388 if !self.layout_ids.insert(layout.id.clone()) {
389 self.add_error(ValidationError::DuplicateId {
390 id: layout.id.clone(),
391 location: "layouts".to_string(),
392 });
393 }
394 self.collect_muxbox_ids_recursive(&layout.children, "muxboxes");
395 }
396 }
397
398 fn collect_muxbox_ids_recursive(&mut self, muxboxes: &Option<Vec<MuxBox>>, location: &str) {
399 if let Some(muxbox_list) = muxboxes {
400 for muxbox in muxbox_list {
401 if !self.muxbox_ids.insert(muxbox.id.clone()) {
402 self.add_error(ValidationError::DuplicateId {
403 id: muxbox.id.clone(),
404 location: location.to_string(),
405 });
406 }
407 self.collect_muxbox_ids_recursive(&muxbox.children, location);
408
409 if let Some(choices) = &muxbox.choices {
411 for choice in choices {
412 if !self.muxbox_ids.insert(choice.id.clone()) {
413 self.add_error(ValidationError::DuplicateId {
414 id: choice.id.clone(),
415 location: "choices".to_string(),
416 });
417 }
418 }
419 }
420 }
421 }
422 }
423
424 fn validate_root_layout_constraints(&mut self, app: &App) {
425 let mut root_count = 0;
426 for layout in &app.layouts {
427 if layout.root == Some(true) {
428 root_count += 1;
429 }
430 }
431
432 if root_count > 1 {
433 self.add_error(ValidationError::SchemaStructure {
434 message:
435 "Multiple root layouts detected. Only one layout can be marked as 'root: true'."
436 .to_string(),
437 });
438 }
439 }
440
441 fn validate_input_bounds_schema(
442 &mut self,
443 bounds: &crate::model::common::InputBounds,
444 path: &str,
445 ) {
446 if bounds.x1.trim().is_empty() {
448 self.add_error(ValidationError::InvalidFieldValue {
449 field: format!("{}.x1", path),
450 value: "empty".to_string(),
451 constraint: "bounds coordinates cannot be empty".to_string(),
452 });
453 }
454
455 if bounds.y1.trim().is_empty() {
456 self.add_error(ValidationError::InvalidFieldValue {
457 field: format!("{}.y1", path),
458 value: "empty".to_string(),
459 constraint: "bounds coordinates cannot be empty".to_string(),
460 });
461 }
462
463 if bounds.x2.trim().is_empty() {
464 self.add_error(ValidationError::InvalidFieldValue {
465 field: format!("{}.x2", path),
466 value: "empty".to_string(),
467 constraint: "bounds coordinates cannot be empty".to_string(),
468 });
469 }
470
471 if bounds.y2.trim().is_empty() {
472 self.add_error(ValidationError::InvalidFieldValue {
473 field: format!("{}.y2", path),
474 value: "empty".to_string(),
475 constraint: "bounds coordinates cannot be empty".to_string(),
476 });
477 }
478 }
479
480 fn validate_json_config_section(&mut self, config: &Map<String, Value>) {
481 if let Some(frame_delay) = config.get("frame_delay") {
482 if let Some(delay) = frame_delay.as_u64() {
483 if delay == 0 {
484 self.add_error(ValidationError::InvalidFieldValue {
485 field: "config.frame_delay".to_string(),
486 value: "0".to_string(),
487 constraint: "frame_delay must be greater than 0".to_string(),
488 });
489 }
490 } else {
491 self.add_error(ValidationError::InvalidFieldType {
492 field: "config.frame_delay".to_string(),
493 expected: "number".to_string(),
494 actual: self.get_json_type(frame_delay),
495 });
496 }
497 }
498 }
499
500 fn get_json_type(&self, value: &Value) -> String {
501 match value {
502 Value::Null => "null".to_string(),
503 Value::Bool(_) => "boolean".to_string(),
504 Value::Number(_) => "number".to_string(),
505 Value::String(_) => "string".to_string(),
506 Value::Array(_) => "array".to_string(),
507 Value::Object(_) => "object".to_string(),
508 }
509 }
510}
511
512impl Default for SchemaValidator {
513 fn default() -> Self {
514 Self::new()
515 }
516}
517
518#[cfg(test)]
519mod tests {
520 use super::*;
521 use crate::model::common::{Config, InputBounds};
522 use crate::{App, Layout, MuxBox};
523
524 fn create_test_muxbox(id: &str) -> MuxBox {
525 MuxBox {
526 id: id.to_string(),
527 position: InputBounds {
528 x1: "10".to_string(),
529 y1: "20".to_string(),
530 x2: "90".to_string(),
531 y2: "80".to_string(),
532 },
533 ..Default::default()
534 }
535 }
536
537 fn create_test_layout(id: &str) -> Layout {
538 Layout {
539 id: id.to_string(),
540 children: Some(vec![create_test_muxbox("muxbox1")]),
541 ..Default::default()
542 }
543 }
544
545 fn create_test_app() -> App {
546 let mut app = App::new();
547 app.layouts = vec![create_test_layout("layout1")];
548 app
549 }
550
551 #[test]
552 fn test_validate_app_success() {
553 let mut validator = SchemaValidator::new();
554 let app = create_test_app();
555
556 let result = validator.validate_app(&app);
557 assert!(result.is_ok());
558 }
559
560 #[test]
561 fn test_validate_app_no_layouts() {
562 let mut validator = SchemaValidator::new();
563 let app = App::new();
564
565 let result = validator.validate_app(&app);
566 assert!(result.is_err());
567
568 let errors = result.unwrap_err();
569 assert_eq!(errors.len(), 1);
570 assert!(matches!(
571 errors[0],
572 ValidationError::MissingRequiredField { .. }
573 ));
574 }
575
576 #[test]
577 fn test_validate_config_success() {
578 let mut validator = SchemaValidator::new();
579 let config = Config::new(60);
580
581 let result = validator.validate_config(&config);
582 assert!(result.is_ok());
583 }
584
585 #[test]
586 fn test_validate_config_zero_frame_delay() {
587 let mut validator = SchemaValidator::new();
588 let config = Config {
589 frame_delay: 0,
590 locked: false,
591 };
592
593 let result = validator.validate_config(&config);
594 assert!(result.is_err());
595
596 let errors = result.unwrap_err();
597 assert_eq!(errors.len(), 1);
598 assert!(matches!(
599 errors[0],
600 ValidationError::InvalidFieldValue { .. }
601 ));
602 }
603
604 #[test]
605 fn test_validate_config_excessive_frame_delay() {
606 let mut validator = SchemaValidator::new();
607 let config = Config {
608 frame_delay: 2000,
609 locked: false,
610 };
611
612 let result = validator.validate_config(&config);
613 assert!(result.is_err());
614
615 let errors = result.unwrap_err();
616 assert_eq!(errors.len(), 1);
617 assert!(matches!(
618 errors[0],
619 ValidationError::InvalidFieldValue { .. }
620 ));
621 }
622
623 #[test]
624 fn test_validate_json_config_success() {
625 let mut validator = SchemaValidator::new();
626 let config = serde_json::json!({
627 "layouts": [
628 {
629 "id": "layout1",
630 "children": [
631 {
632 "id": "muxbox1",
633 "bounds": {
634 "x1": "10",
635 "y1": "20",
636 "x2": "90",
637 "y2": "80"
638 }
639 }
640 ]
641 }
642 ],
643 "config": {
644 "frame_delay": 60
645 }
646 });
647
648 let result = validator.validate_json_config(&config);
649 assert!(result.is_ok());
650 }
651
652 #[test]
653 fn test_validate_json_config_missing_layouts() {
654 let mut validator = SchemaValidator::new();
655 let config = serde_json::json!({
656 "config": {
657 "frame_delay": 60
658 }
659 });
660
661 let result = validator.validate_json_config(&config);
662 assert!(result.is_err());
663
664 let errors = result.unwrap_err();
665 assert_eq!(errors.len(), 1);
666 assert!(matches!(
667 errors[0],
668 ValidationError::MissingRequiredField { .. }
669 ));
670 }
671
672 #[test]
673 fn test_validate_empty_bounds() {
674 let mut validator = SchemaValidator::new();
675 let muxbox = MuxBox {
676 id: "test_muxbox".to_string(),
677 position: InputBounds {
678 x1: "".to_string(), y1: "20".to_string(),
680 x2: "90".to_string(),
681 y2: "80".to_string(),
682 },
683 ..Default::default()
684 };
685
686 let result = validator.validate_muxbox(&muxbox, "test_muxbox");
687 assert!(result.is_err());
688
689 let errors = result.unwrap_err();
690 assert!(errors
691 .iter()
692 .any(|e| matches!(e, ValidationError::InvalidFieldValue { .. })));
693 }
694
695 #[test]
696 fn test_validation_error_formatting() {
697 let duplicate_error = ValidationError::DuplicateId {
699 id: "muxbox1".to_string(),
700 location: "muxboxes".to_string(),
701 };
702 assert_eq!(
703 duplicate_error.to_string(),
704 "Duplicate ID 'muxbox1' found in muxboxes"
705 );
706
707 let missing_field_error = ValidationError::MissingRequiredField {
708 field: "layouts".to_string(),
709 };
710 assert_eq!(
711 missing_field_error.to_string(),
712 "Required field 'layouts' is missing"
713 );
714
715 let schema_error = ValidationError::SchemaStructure {
716 message:
717 "Multiple root layouts detected. Only one layout can be marked as 'root: true'."
718 .to_string(),
719 };
720 assert_eq!(schema_error.to_string(), "Schema structure error: Multiple root layouts detected. Only one layout can be marked as 'root: true'.");
721 }
722
723 #[test]
724 fn test_json_schema_validation_success() {
725 let mut validator = SchemaValidator::new();
726 let yaml_content = r#"
727app:
728 layouts:
729 - id: 'test_layout'
730 title: 'Test Layout'
731 children:
732 - id: 'muxbox1'
733 position:
734 x1: "0%"
735 y1: "0%"
736 x2: "100%"
737 y2: "100%"
738 content: 'Test content'
739 tab_order: 1
740"#;
741
742 let result = validator.validate_with_json_schema(yaml_content, "schemas");
743
744 match result {
746 Ok(_) => {
747 assert!(true);
749 }
750 Err(errors) => {
751 let error_messages: Vec<String> = errors.iter().map(|e| e.to_string()).collect();
753 let combined = error_messages.join("; ");
754 assert!(
755 combined.contains("Failed to load schema file")
756 || combined.contains("No such file")
757 );
758 }
759 }
760 }
761
762 #[test]
763 fn test_json_schema_validation_invalid_yaml() {
764 let mut validator = SchemaValidator::new();
765 let invalid_yaml = r#"
766app:
767 layouts:
768 - id: 'test'
769 children:
770 - id: 'muxbox1'
771 position:
772 x1: "0%"
773 y1: "0%"
774 x2: "100%"
775 # Missing y2 - should cause validation error
776 border_color: 'invalid_color' # Invalid color
777"#;
778
779 let result = validator.validate_with_json_schema(invalid_yaml, "schemas");
780
781 match result {
782 Ok(_) => {
783 assert!(true);
786 }
787 Err(errors) => {
788 assert!(!errors.is_empty());
790 let error_messages: Vec<String> = errors.iter().map(|e| e.to_string()).collect();
791 let combined = error_messages.join("; ");
792 assert!(
794 combined.contains("JSON Schema validation error")
795 || combined.contains("Failed to load schema file")
796 || combined.contains("No such file")
797 );
798 }
799 }
800 }
801
802 #[test]
803 fn test_json_schema_validation_malformed_yaml() {
804 let mut validator = SchemaValidator::new();
805 let malformed_yaml = r#"
806app:
807 layouts:
808 - id: 'test'
809 children:
810 - id: 'muxbox1'
811 position:
812 x1: "0%"
813 y1: "0%"
814 x2: "100%"
815 y2: "100%"
816 invalid_field_that_should_not_exist: 'invalid'
817 border_color: 123 # Wrong type - should be string
818"#;
819
820 let result = validator.validate_with_json_schema(malformed_yaml, "schemas");
821
822 match result {
823 Ok(_) => {
824 assert!(true);
826 }
827 Err(errors) => {
828 assert!(!errors.is_empty());
829 for error in &errors {
831 match error {
832 ValidationError::JsonSchemaValidation { field, message } => {
833 assert!(!field.is_empty());
834 assert!(!message.is_empty());
835 }
836 ValidationError::SchemaStructure { message } => {
837 assert!(!message.is_empty());
838 }
839 _ => {} }
841 }
842 }
843 }
844 }
845
846 #[test]
847 fn test_json_schema_validation_error_formatting() {
848 let json_schema_error = ValidationError::JsonSchemaValidation {
849 field: "app.layouts[0].children[0].border_color".to_string(),
850 message: "invalid_color is not one of the allowed values".to_string(),
851 };
852
853 let formatted = json_schema_error.to_string();
854 assert!(formatted.contains("JSON Schema validation error"));
855 assert!(formatted.contains("app.layouts[0].children[0].border_color"));
856 assert!(formatted.contains("invalid_color is not one of the allowed values"));
857 }
858
859 #[test]
860 fn test_validate_against_schema_file_missing_file() {
861 let mut validator = SchemaValidator::new();
862 let test_value = serde_json::json!({
863 "test": "value"
864 });
865
866 let result =
867 validator.validate_against_schema_file(&test_value, "nonexistent/schema.json", "test");
868
869 assert!(result.is_err());
870 let errors = result.unwrap_err();
871 assert_eq!(errors.len(), 1);
872 assert!(matches!(errors[0], ValidationError::SchemaStructure { .. }));
873 assert!(errors[0].to_string().contains("Failed to load schema file"));
874 }
875
876 #[test]
877 fn test_comprehensive_validation_with_multiple_errors() {
878 let mut validator = SchemaValidator::new();
879
880 let mut app = App::new();
882
883 let mut layout1 = create_test_layout("layout1");
885 layout1.root = Some(true);
886 let mut layout2 = create_test_layout("layout2");
887 layout2.root = Some(true); let muxbox1 = create_test_muxbox("muxbox1");
891 let muxbox1_dup = create_test_muxbox("muxbox1"); layout1.children = Some(vec![muxbox1]);
893 layout2.children = Some(vec![muxbox1_dup]);
894
895 app.layouts = vec![layout1, layout2];
896
897 let result = validator.validate_app(&app);
898 assert!(result.is_err());
899
900 let errors = result.unwrap_err();
901 assert!(
902 errors.len() >= 2,
903 "Should have at least 2 validation errors"
904 );
905
906 assert!(errors
908 .iter()
909 .any(|e| matches!(e, ValidationError::SchemaStructure { .. })));
910
911 assert!(errors
913 .iter()
914 .any(|e| matches!(e, ValidationError::DuplicateId { .. })));
915 }
916}