1use crate::error::BuildError;
139use crate::presets::{DdexVersion, MessageProfile, PartnerPreset};
140use serde::{Serialize, Deserialize};
141use serde_json::{json, Value as JsonValue};
142use indexmap::IndexMap;
143
144mod generators;
145mod validation;
146mod types;
147
148pub use validation::{SchemaValidator, ValidationConfig as SchemaValidationConfig, ValidationResult as SchemaValidationResult};
150
151#[derive(Debug, Clone)]
153pub struct SchemaGenerator {
154 version: DdexVersion,
156 profile: MessageProfile,
158 preset: Option<PartnerPreset>,
160 config: SchemaConfig,
162}
163
164#[derive(Debug, Clone)]
166pub struct SchemaConfig {
167 pub draft_version: SchemaDraft,
169 pub include_examples: bool,
171 pub include_descriptions: bool,
173 pub strict_validation: bool,
175 pub include_deprecated: bool,
177 pub version_conditionals: bool,
179}
180
181#[derive(Debug, Clone, Copy)]
183pub enum SchemaDraft {
184 Draft202012,
186 Draft07,
188}
189
190#[derive(Debug, Clone, Serialize, Deserialize)]
192pub struct JsonSchema {
193 #[serde(rename = "$schema")]
195 pub schema: String,
196 #[serde(rename = "$id", skip_serializing_if = "Option::is_none")]
198 pub id: Option<String>,
199 #[serde(skip_serializing_if = "Option::is_none")]
201 pub title: Option<String>,
202 #[serde(skip_serializing_if = "Option::is_none")]
204 pub description: Option<String>,
205 #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
207 pub schema_type: Option<String>,
208 #[serde(skip_serializing_if = "Option::is_none")]
210 pub properties: Option<IndexMap<String, JsonSchema>>,
211 #[serde(skip_serializing_if = "Option::is_none")]
213 pub required: Option<Vec<String>>,
214 #[serde(rename = "additionalProperties", skip_serializing_if = "Option::is_none")]
216 pub additional_properties: Option<bool>,
217 #[serde(skip_serializing_if = "Option::is_none")]
219 pub items: Option<Box<JsonSchema>>,
220 #[serde(rename = "enum", skip_serializing_if = "Option::is_none")]
222 pub enum_values: Option<Vec<JsonValue>>,
223 #[serde(skip_serializing_if = "Option::is_none")]
225 pub pattern: Option<String>,
226 #[serde(skip_serializing_if = "Option::is_none")]
228 pub format: Option<String>,
229 #[serde(rename = "minLength", skip_serializing_if = "Option::is_none")]
231 pub min_length: Option<usize>,
232 #[serde(rename = "maxLength", skip_serializing_if = "Option::is_none")]
234 pub max_length: Option<usize>,
235 #[serde(skip_serializing_if = "Option::is_none")]
237 pub examples: Option<Vec<JsonValue>>,
238 #[serde(rename = "$defs", skip_serializing_if = "Option::is_none")]
240 pub definitions: Option<IndexMap<String, JsonSchema>>,
241 #[serde(rename = "$ref", skip_serializing_if = "Option::is_none")]
243 pub reference: Option<String>,
244 #[serde(rename = "allOf", skip_serializing_if = "Option::is_none")]
246 pub all_of: Option<Vec<JsonSchema>>,
247 #[serde(rename = "anyOf", skip_serializing_if = "Option::is_none")]
249 pub any_of: Option<Vec<JsonSchema>>,
250 #[serde(rename = "oneOf", skip_serializing_if = "Option::is_none")]
252 pub one_of: Option<Vec<JsonSchema>>,
253 #[serde(rename = "if", skip_serializing_if = "Option::is_none")]
255 pub if_schema: Option<Box<JsonSchema>>,
256 #[serde(rename = "then", skip_serializing_if = "Option::is_none")]
258 pub then_schema: Option<Box<JsonSchema>>,
259 #[serde(rename = "else", skip_serializing_if = "Option::is_none")]
261 pub else_schema: Option<Box<JsonSchema>>,
262 #[serde(flatten)]
264 pub annotations: IndexMap<String, JsonValue>,
265}
266
267#[derive(Debug, Clone)]
269pub struct SchemaGenerationResult {
270 pub schema: JsonSchema,
272 pub metadata: SchemaMetadata,
274 pub warnings: Vec<SchemaWarning>,
276}
277
278#[derive(Debug, Clone)]
280pub struct SchemaMetadata {
281 pub ddex_version: DdexVersion,
283 pub profile: MessageProfile,
285 pub draft_version: SchemaDraft,
287 pub generated_at: chrono::DateTime<chrono::Utc>,
289 pub property_count: usize,
291 pub required_count: usize,
293 pub complexity_score: f64,
295}
296
297#[derive(Debug, Clone)]
299pub struct SchemaWarning {
300 pub code: String,
302 pub message: String,
304 pub field_path: Option<String>,
306 pub suggestion: Option<String>,
308}
309
310impl Default for SchemaConfig {
311 fn default() -> Self {
312 Self {
313 draft_version: SchemaDraft::Draft202012,
314 include_examples: true,
315 include_descriptions: true,
316 strict_validation: true,
317 include_deprecated: false,
318 version_conditionals: true,
319 }
320 }
321}
322
323impl SchemaGenerator {
324 pub fn new(version: DdexVersion, profile: MessageProfile) -> Self {
326 Self {
327 version,
328 profile,
329 preset: None,
330 config: SchemaConfig::default(),
331 }
332 }
333
334 pub fn with_preset(version: DdexVersion, profile: MessageProfile, preset: PartnerPreset) -> Self {
336 Self {
337 version,
338 profile,
339 preset: Some(preset),
340 config: SchemaConfig::default(),
341 }
342 }
343
344 pub fn with_config(mut self, config: SchemaConfig) -> Self {
346 self.config = config;
347 self
348 }
349
350 pub fn generate_build_request_schema(&self) -> Result<SchemaGenerationResult, BuildError> {
352 let mut warnings = Vec::new();
353
354 let schema = self.build_request_schema(&mut warnings)?;
355
356 let metadata = SchemaMetadata {
357 ddex_version: self.version,
358 profile: self.profile,
359 draft_version: self.config.draft_version,
360 generated_at: chrono::Utc::now(),
361 property_count: self.count_properties(&schema),
362 required_count: schema.required.as_ref().map(|r| r.len()).unwrap_or(0),
363 complexity_score: self.calculate_complexity(&schema),
364 };
365
366 Ok(SchemaGenerationResult {
367 schema,
368 metadata,
369 warnings,
370 })
371 }
372
373 pub fn generate_flat_release_schema(&self) -> Result<SchemaGenerationResult, BuildError> {
375 let mut warnings = Vec::new();
376
377 let schema = self.flat_release_schema(&mut warnings)?;
378
379 let metadata = SchemaMetadata {
380 ddex_version: self.version,
381 profile: self.profile,
382 draft_version: self.config.draft_version,
383 generated_at: chrono::Utc::now(),
384 property_count: self.count_properties(&schema),
385 required_count: schema.required.as_ref().map(|r| r.len()).unwrap_or(0),
386 complexity_score: self.calculate_complexity(&schema),
387 };
388
389 Ok(SchemaGenerationResult {
390 schema,
391 metadata,
392 warnings,
393 })
394 }
395
396 pub fn generate_complete_schema(&self) -> Result<SchemaGenerationResult, BuildError> {
398 let mut warnings = Vec::new();
399 let mut definitions = IndexMap::new();
400
401 definitions.insert("BuildRequest".to_string(), self.build_request_schema(&mut warnings)?);
403 definitions.insert("ReleaseRequest".to_string(), self.release_request_schema(&mut warnings)?);
404 definitions.insert("TrackRequest".to_string(), self.track_request_schema(&mut warnings)?);
405 definitions.insert("DealRequest".to_string(), self.deal_request_schema(&mut warnings)?);
406 definitions.insert("MessageHeader".to_string(), self.message_header_schema(&mut warnings)?);
407
408 definitions.extend(self.common_type_definitions(&mut warnings)?);
410
411 let schema = JsonSchema {
412 schema: self.schema_draft_url(),
413 id: Some("https://ddex.net/schema/ern/builder".to_string()),
414 title: Some(format!("DDEX Builder Schema - ERN {} {}",
415 self.version_string(), self.profile_string())),
416 description: Some(format!(
417 "Complete JSON Schema for DDEX Builder structures targeting ERN {} with {} profile",
418 self.version_string(), self.profile_string()
419 )),
420 schema_type: Some("object".to_string()),
421 definitions: Some(definitions),
422 additional_properties: Some(false),
423 ..Default::default()
424 };
425
426 let metadata = SchemaMetadata {
427 ddex_version: self.version,
428 profile: self.profile,
429 draft_version: self.config.draft_version,
430 generated_at: chrono::Utc::now(),
431 property_count: self.count_properties(&schema),
432 required_count: 0, complexity_score: self.calculate_complexity(&schema),
434 };
435
436 Ok(SchemaGenerationResult {
437 schema,
438 metadata,
439 warnings,
440 })
441 }
442
443 pub fn generate_typescript_types(&self, schema: &JsonSchema) -> Result<String, BuildError> {
445 let mut typescript = String::new();
446
447 typescript.push_str(&format!(
448 "// Generated TypeScript types for DDEX Builder - ERN {} {}\n",
449 self.version_string(), self.profile_string()
450 ));
451 typescript.push_str("// Generated at: ");
452 typescript.push_str(&chrono::Utc::now().to_rfc3339());
453 typescript.push_str("\n\n");
454
455 if let Some(ref definitions) = schema.definitions {
456 for (name, def_schema) in definitions {
457 typescript.push_str(&self.schema_to_typescript(name, def_schema)?);
458 typescript.push_str("\n\n");
459 }
460 }
461
462 Ok(typescript)
463 }
464
465 pub fn generate_python_types(&self, schema: &JsonSchema) -> Result<String, BuildError> {
467 let mut python = String::new();
468
469 python.push_str(&format!(
470 "# Generated Python TypedDict types for DDEX Builder - ERN {} {}\n",
471 self.version_string(), self.profile_string()
472 ));
473 python.push_str("# Generated at: ");
474 python.push_str(&chrono::Utc::now().to_rfc3339());
475 python.push_str("\n\n");
476 python.push_str("from typing import TypedDict, Optional, List, Union, Literal\nfrom datetime import datetime\n\n");
477
478 if let Some(ref definitions) = schema.definitions {
479 for (name, def_schema) in definitions {
480 python.push_str(&self.schema_to_python(name, def_schema)?);
481 python.push_str("\n\n");
482 }
483 }
484
485 Ok(python)
486 }
487
488 fn schema_draft_url(&self) -> String {
491 match self.config.draft_version {
492 SchemaDraft::Draft202012 => "https://json-schema.org/draft/2020-12/schema".to_string(),
493 SchemaDraft::Draft07 => "https://json-schema.org/draft-07/schema".to_string(),
494 }
495 }
496
497 fn version_string(&self) -> &str {
498 match self.version {
499 DdexVersion::Ern43 => "4.3",
500 DdexVersion::Ern42 => "4.2",
501 DdexVersion::Ern41 => "4.1",
502 DdexVersion::Ern382 => "3.8.2",
503 }
504 }
505
506 fn profile_string(&self) -> &str {
507 match self.profile {
508 MessageProfile::AudioAlbum => "AudioAlbum",
509 MessageProfile::AudioSingle => "AudioSingle",
510 MessageProfile::VideoAlbum => "VideoAlbum",
511 MessageProfile::VideoSingle => "VideoSingle",
512 MessageProfile::Mixed => "Mixed",
513 }
514 }
515
516 fn count_properties(&self, schema: &JsonSchema) -> usize {
517 let mut count = 0;
518
519 if let Some(ref properties) = schema.properties {
520 count += properties.len();
521 for (_, prop_schema) in properties {
522 count += self.count_properties(prop_schema);
523 }
524 }
525
526 if let Some(ref definitions) = schema.definitions {
527 for (_, def_schema) in definitions {
528 count += self.count_properties(def_schema);
529 }
530 }
531
532 count
533 }
534
535 fn calculate_complexity(&self, schema: &JsonSchema) -> f64 {
536 let mut complexity = 0.0;
537
538 if let Some(ref properties) = schema.properties {
540 complexity += properties.len() as f64;
541
542 for (_, prop_schema) in properties {
543 complexity += self.calculate_complexity(prop_schema) * 0.5;
544 }
545 }
546
547 if schema.all_of.is_some() { complexity += 2.0; }
549 if schema.any_of.is_some() { complexity += 3.0; }
550 if schema.one_of.is_some() { complexity += 4.0; }
551 if schema.if_schema.is_some() { complexity += 5.0; }
552 if schema.pattern.is_some() { complexity += 1.0; }
553 if schema.enum_values.is_some() { complexity += 0.5; }
554
555 complexity
556 }
557}
558
559impl Default for JsonSchema {
560 fn default() -> Self {
561 Self {
562 schema: String::new(),
563 id: None,
564 title: None,
565 description: None,
566 schema_type: None,
567 properties: None,
568 required: None,
569 additional_properties: None,
570 items: None,
571 enum_values: None,
572 pattern: None,
573 format: None,
574 min_length: None,
575 max_length: None,
576 examples: None,
577 definitions: None,
578 reference: None,
579 all_of: None,
580 any_of: None,
581 one_of: None,
582 if_schema: None,
583 then_schema: None,
584 else_schema: None,
585 annotations: IndexMap::new(),
586 }
587 }
588}
589
590impl std::fmt::Display for SchemaDraft {
591 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
592 match self {
593 SchemaDraft::Draft202012 => write!(f, "2020-12"),
594 SchemaDraft::Draft07 => write!(f, "draft-07"),
595 }
596 }
597}
598
599#[derive(Debug, Clone)]
601pub struct SchemaCommand {
602 pub version: String,
604 pub profile: String,
606 pub output: Option<String>,
608 pub typescript: bool,
610 pub python: bool,
612 pub examples: bool,
614 pub strict: bool,
616}
617
618impl SchemaCommand {
619 pub fn execute(&self) -> Result<(), BuildError> {
621 let version = self.parse_version()?;
622 let profile = self.parse_profile()?;
623
624 let config = SchemaConfig {
625 include_examples: self.examples,
626 strict_validation: self.strict,
627 ..Default::default()
628 };
629
630 let generator = SchemaGenerator::new(version, profile).with_config(config);
631 let result = generator.generate_complete_schema()?;
632
633 let schema_json = serde_json::to_string_pretty(&result.schema)
635 .map_err(|e| BuildError::InvalidFormat {
636 field: "schema".to_string(),
637 message: format!("Failed to serialize schema: {}", e),
638 })?;
639
640 if let Some(ref output_path) = self.output {
641 std::fs::write(output_path, &schema_json)
642 .map_err(|e| BuildError::InvalidFormat {
643 field: "output".to_string(),
644 message: format!("Failed to write schema: {}", e),
645 })?;
646 println!("Schema written to: {}", output_path);
647 } else {
648 println!("{}", schema_json);
649 }
650
651 if self.typescript {
653 let ts_types = generator.generate_typescript_types(&result.schema)?;
654 let ts_path = self.output.as_ref()
655 .map(|p| p.replace(".json", ".d.ts"))
656 .unwrap_or_else(|| "ddex-types.d.ts".to_string());
657
658 std::fs::write(&ts_path, ts_types)
659 .map_err(|e| BuildError::InvalidFormat {
660 field: "typescript".to_string(),
661 message: format!("Failed to write TypeScript types: {}", e),
662 })?;
663 println!("TypeScript types written to: {}", ts_path);
664 }
665
666 if self.python {
668 let py_types = generator.generate_python_types(&result.schema)?;
669 let py_path = self.output.as_ref()
670 .map(|p| p.replace(".json", ".py"))
671 .unwrap_or_else(|| "ddex_types.py".to_string());
672
673 std::fs::write(&py_path, py_types)
674 .map_err(|e| BuildError::InvalidFormat {
675 field: "python".to_string(),
676 message: format!("Failed to write Python types: {}", e),
677 })?;
678 println!("Python types written to: {}", py_path);
679 }
680
681 println!("\nSchema Generation Complete:");
683 println!(" DDEX Version: ERN {}", generator.version_string());
684 println!(" Profile: {}", generator.profile_string());
685 println!(" Properties: {}", result.metadata.property_count);
686 println!(" Required Fields: {}", result.metadata.required_count);
687 println!(" Complexity Score: {:.1}", result.metadata.complexity_score);
688
689 if !result.warnings.is_empty() {
690 println!("\nWarnings:");
691 for warning in &result.warnings {
692 println!(" {}: {}", warning.code, warning.message);
693 if let Some(ref path) = warning.field_path {
694 println!(" Field: {}", path);
695 }
696 if let Some(ref suggestion) = warning.suggestion {
697 println!(" Suggestion: {}", suggestion);
698 }
699 }
700 }
701
702 Ok(())
703 }
704
705 fn parse_version(&self) -> Result<DdexVersion, BuildError> {
706 match self.version.as_str() {
707 "4.3" | "43" => Ok(DdexVersion::Ern43),
708 "4.2" | "42" => Ok(DdexVersion::Ern42),
709 "4.1" | "41" => Ok(DdexVersion::Ern41),
710 "3.8.2" | "382" => Ok(DdexVersion::Ern382),
711 _ => Err(BuildError::InvalidFormat {
712 field: "version".to_string(),
713 message: format!("Unsupported DDEX version: {}", self.version),
714 }),
715 }
716 }
717
718 fn parse_profile(&self) -> Result<MessageProfile, BuildError> {
719 match self.profile.to_lowercase().as_str() {
720 "audioalbum" | "audio-album" => Ok(MessageProfile::AudioAlbum),
721 "audiosingle" | "audio-single" => Ok(MessageProfile::AudioSingle),
722 "videoalbum" | "video-album" => Ok(MessageProfile::VideoAlbum),
723 "videosingle" | "video-single" => Ok(MessageProfile::VideoSingle),
724 "mixed" => Ok(MessageProfile::Mixed),
725 _ => Err(BuildError::InvalidFormat {
726 field: "profile".to_string(),
727 message: format!("Unsupported message profile: {}", self.profile),
728 }),
729 }
730 }
731}
732
733#[cfg(test)]
734mod tests {
735 use super::*;
736
737 #[test]
738 fn test_schema_generator_creation() {
739 let generator = SchemaGenerator::new(DdexVersion::Ern43, MessageProfile::AudioAlbum);
740 assert_eq!(generator.version, DdexVersion::Ern43);
741 assert_eq!(generator.profile, MessageProfile::AudioAlbum);
742 assert!(generator.preset.is_none());
743 }
744
745 #[test]
746 fn test_schema_config_defaults() {
747 let config = SchemaConfig::default();
748
749 assert!(matches!(config.draft_version, SchemaDraft::Draft202012));
750 assert!(config.include_examples);
751 assert!(config.include_descriptions);
752 assert!(config.strict_validation);
753 assert!(!config.include_deprecated);
754 assert!(config.version_conditionals);
755 }
756
757 #[test]
758 fn test_build_request_schema_generation() {
759 let generator = SchemaGenerator::new(DdexVersion::Ern43, MessageProfile::AudioAlbum);
760 let result = generator.generate_build_request_schema().unwrap();
761
762 assert!(result.schema.title.is_some());
763 assert!(result.schema.schema_type == Some("object".to_string()));
764 assert!(result.schema.properties.is_some());
765 assert!(result.schema.required.is_some());
766
767 let properties = result.schema.properties.unwrap();
768 assert!(properties.contains_key("header"));
769 assert!(properties.contains_key("releases"));
770
771 let required = result.schema.required.unwrap();
772 assert!(required.contains(&"header".to_string()));
773 assert!(required.contains(&"releases".to_string()));
774
775 assert!(result.metadata.property_count > 0);
777 assert!(result.metadata.complexity_score > 0.0);
778 }
779
780 #[test]
781 fn test_complete_schema_generation() {
782 let generator = SchemaGenerator::new(DdexVersion::Ern43, MessageProfile::AudioAlbum);
783 let result = generator.generate_complete_schema().unwrap();
784
785 assert!(result.schema.definitions.is_some());
786
787 let definitions = result.schema.definitions.unwrap();
788 assert!(definitions.contains_key("BuildRequest"));
789 assert!(definitions.contains_key("ReleaseRequest"));
790 assert!(definitions.contains_key("TrackRequest"));
791 assert!(definitions.contains_key("DealRequest"));
792 assert!(definitions.contains_key("MessageHeader"));
793 assert!(definitions.contains_key("LocalizedString"));
794 assert!(definitions.contains_key("Party"));
795 assert!(definitions.contains_key("DealTerms"));
796
797 assert_eq!(result.schema.schema, "https://json-schema.org/draft/2020-12/schema");
799 assert!(result.schema.id.is_some());
800 assert!(result.schema.title.is_some());
801 assert!(result.schema.description.is_some());
802 }
803
804 #[test]
805 fn test_version_strings() {
806 let generator_43 = SchemaGenerator::new(DdexVersion::Ern43, MessageProfile::AudioAlbum);
807 let generator_42 = SchemaGenerator::new(DdexVersion::Ern42, MessageProfile::AudioAlbum);
808 let generator_41 = SchemaGenerator::new(DdexVersion::Ern41, MessageProfile::AudioAlbum);
809
810 assert_eq!(generator_43.version_string(), "4.3");
811 assert_eq!(generator_42.version_string(), "4.2");
812 assert_eq!(generator_41.version_string(), "4.1");
813 }
814
815 #[test]
816 fn test_profile_strings() {
817 let audio_album = SchemaGenerator::new(DdexVersion::Ern43, MessageProfile::AudioAlbum);
818 let audio_single = SchemaGenerator::new(DdexVersion::Ern43, MessageProfile::AudioSingle);
819 let video_album = SchemaGenerator::new(DdexVersion::Ern43, MessageProfile::VideoAlbum);
820 let video_single = SchemaGenerator::new(DdexVersion::Ern43, MessageProfile::VideoSingle);
821 let mixed = SchemaGenerator::new(DdexVersion::Ern43, MessageProfile::Mixed);
822
823 assert_eq!(audio_album.profile_string(), "AudioAlbum");
824 assert_eq!(audio_single.profile_string(), "AudioSingle");
825 assert_eq!(video_album.profile_string(), "VideoAlbum");
826 assert_eq!(video_single.profile_string(), "VideoSingle");
827 assert_eq!(mixed.profile_string(), "Mixed");
828 }
829
830 #[test]
831 fn test_schema_command_parsing() {
832 let command = SchemaCommand {
833 version: "4.3".to_string(),
834 profile: "AudioAlbum".to_string(),
835 output: Some("schema.json".to_string()),
836 typescript: true,
837 python: true,
838 examples: true,
839 strict: true,
840 };
841
842 let parsed_version = command.parse_version().unwrap();
843 let parsed_profile = command.parse_profile().unwrap();
844
845 assert!(matches!(parsed_version, DdexVersion::Ern43));
846 assert!(matches!(parsed_profile, MessageProfile::AudioAlbum));
847 }
848}