ddex_builder/
preflight.rs

1// packages/ddex-builder/src/preflight.rs
2//! Comprehensive preflight validation for DDEX messages
3
4use regex::Regex;
5use once_cell::sync::Lazy;
6use serde::{Deserialize, Serialize};
7
8// Validation regex patterns
9static ISRC_PATTERN: Lazy<Regex> = Lazy::new(|| {
10    Regex::new(r"^[A-Z]{2}[A-Z0-9]{3}\d{2}\d{5}$").unwrap()
11});
12
13static UPC_PATTERN: Lazy<Regex> = Lazy::new(|| {
14    Regex::new(r"^\d{12,14}$").unwrap()
15});
16
17static ISWC_PATTERN: Lazy<Regex> = Lazy::new(|| {
18    Regex::new(r"^T\d{10}$").unwrap()
19});
20
21static ISNI_PATTERN: Lazy<Regex> = Lazy::new(|| {
22    Regex::new(r"^\d{15}[\dX]$").unwrap()
23});
24
25/// Preflight validator for DDEX messages
26pub struct PreflightValidator {
27    config: ValidationConfig,
28}
29
30/// Validation configuration
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct ValidationConfig {
33    /// Validation level
34    pub level: PreflightLevel,
35    
36    /// Validate identifier formats
37    pub validate_identifiers: bool,
38    
39    /// Validate checksums
40    pub validate_checksums: bool,
41    
42    /// Check for required fields
43    pub check_required_fields: bool,
44    
45    /// Validate dates
46    pub validate_dates: bool,
47    
48    /// Check references
49    pub validate_references: bool,
50    
51    /// Profile-specific validation
52    pub profile: Option<String>,
53}
54
55impl Default for ValidationConfig {
56    fn default() -> Self {
57        Self {
58            level: PreflightLevel::Warn,
59            validate_identifiers: true,
60            validate_checksums: true,
61            check_required_fields: true,
62            validate_dates: true,
63            validate_references: true,
64            profile: None,
65        }
66    }
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
70pub enum PreflightLevel {
71    /// Strict - fail on any warning
72    Strict,
73    /// Warn - collect warnings but continue
74    Warn,
75    /// None - skip validation
76    None,
77}
78
79/// Validation result
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct ValidationResult {
82    pub errors: Vec<ValidationError>,
83    pub warnings: Vec<ValidationWarning>,
84    pub info: Vec<ValidationInfo>,
85    pub passed: bool,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct ValidationError {
90    pub code: String,
91    pub field: String,
92    pub message: String,
93    pub location: String,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct ValidationWarning {
98    pub code: String,
99    pub field: String,
100    pub message: String,
101    pub location: String,
102    pub suggestion: Option<String>,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct ValidationInfo {
107    pub code: String,
108    pub message: String,
109}
110
111impl PreflightValidator {
112    /// Create new validator
113    pub fn new(config: ValidationConfig) -> Self {
114        Self { config }
115    }
116    
117    /// Validate a build request
118    pub fn validate(
119        &self,
120        request: &super::builder::BuildRequest,
121    ) -> Result<ValidationResult, super::error::BuildError> {
122        let mut result = ValidationResult {
123            errors: Vec::new(),
124            warnings: Vec::new(),
125            info: Vec::new(),
126            passed: true,
127        };
128        
129        if self.config.level == PreflightLevel::None {
130            return Ok(result);
131        }
132        
133        // Validate releases
134        for (idx, release) in request.releases.iter().enumerate() {
135            self.validate_release(release, idx, &mut result)?;
136        }
137        
138        // Validate deals
139        for (idx, deal) in request.deals.iter().enumerate() {
140            self.validate_deal(deal, idx, &mut result)?;
141        }
142        
143        // Check cross-references if enabled
144        if self.config.validate_references {
145            self.validate_references(request, &mut result)?;
146        }
147        
148        // Apply profile-specific validation
149        if let Some(profile) = &self.config.profile {
150            self.validate_profile(request, profile, &mut result)?;
151        }
152        
153        // Determine if validation passed
154        result.passed = result.errors.is_empty() && 
155            (self.config.level != PreflightLevel::Strict || result.warnings.is_empty());
156        
157        Ok(result)
158    }
159    
160    fn validate_release(
161        &self,
162        release: &super::builder::ReleaseRequest,
163        idx: usize,
164        result: &mut ValidationResult,
165    ) -> Result<(), super::error::BuildError> {
166        let location = format!("/releases[{}]", idx);
167        
168        // Check required fields
169        if self.config.check_required_fields {
170            if release.title.is_empty() {
171                result.errors.push(ValidationError {
172                    code: "MISSING_TITLE".to_string(),
173                    field: "title".to_string(),
174                    message: "Release title is required".to_string(),
175                    location: format!("{}/title", location),
176                });
177            }
178            
179            if release.artist.is_empty() {
180                result.warnings.push(ValidationWarning {
181                    code: "MISSING_ARTIST".to_string(),
182                    field: "artist".to_string(),
183                    message: "Release artist is recommended".to_string(),
184                    location: format!("{}/artist", location),
185                    suggestion: Some("Add display artist name".to_string()),
186                });
187            }
188        }
189        
190        // Validate UPC
191        if self.config.validate_identifiers {
192            if let Some(upc) = &release.upc {
193                if !self.validate_upc(upc) {
194                    result.errors.push(ValidationError {
195                        code: "INVALID_UPC".to_string(),
196                        field: "upc".to_string(),
197                        message: format!("Invalid UPC format: {}", upc),
198                        location: format!("{}/upc", location),
199                    });
200                }
201            }
202        }
203        
204        // Validate tracks
205        for (track_idx, track) in release.tracks.iter().enumerate() {
206            self.validate_track(track, idx, track_idx, result)?;
207        }
208        
209        Ok(())
210    }
211    
212    fn validate_track(
213        &self,
214        track: &super::builder::TrackRequest,
215        release_idx: usize,
216        track_idx: usize,
217        result: &mut ValidationResult,
218    ) -> Result<(), super::error::BuildError> {
219        let location = format!("/releases[{}]/tracks[{}]", release_idx, track_idx);
220        
221        // Validate ISRC
222        if self.config.validate_identifiers {
223            if !self.validate_isrc(&track.isrc) {
224                result.errors.push(ValidationError {
225                    code: "INVALID_ISRC".to_string(),
226                    field: "isrc".to_string(),
227                    message: format!("Invalid ISRC format: {}", track.isrc),
228                    location: format!("{}/isrc", location),
229                });
230            }
231        }
232        
233        // Validate duration format
234        if !track.duration.is_empty() && !self.validate_duration(&track.duration) {
235            result.warnings.push(ValidationWarning {
236                code: "INVALID_DURATION".to_string(),
237                field: "duration".to_string(),
238                message: format!("Invalid ISO 8601 duration: {}", track.duration),
239                location: format!("{}/duration", location),
240                suggestion: Some("Use format PT3M45S for 3:45".to_string()),
241            });
242        }
243        
244        Ok(())
245    }
246    
247    fn validate_deal(
248        &self,
249        deal: &super::builder::DealRequest,
250        idx: usize,
251        result: &mut ValidationResult,
252    ) -> Result<(), super::error::BuildError> {
253        let location = format!("/deals[{}]", idx);
254        
255        // Validate territory codes
256        for (t_idx, territory) in deal.deal_terms.territory_code.iter().enumerate() {
257            if !self.validate_territory_code(territory) {
258                result.warnings.push(ValidationWarning {
259                    code: "INVALID_TERRITORY".to_string(),
260                    field: "territory_code".to_string(),
261                    message: format!("Invalid territory code: {}", territory),
262                    location: format!("{}/territory_code[{}]", location, t_idx),
263                    suggestion: Some("Use ISO 3166-1 alpha-2 codes".to_string()),
264                });
265            }
266        }
267        
268        Ok(())
269    }
270    
271    fn validate_references(
272        &self,
273        request: &super::builder::BuildRequest,
274        result: &mut ValidationResult,
275    ) -> Result<(), super::error::BuildError> {
276        // Collect all references
277        let mut release_refs = indexmap::IndexSet::new();
278        let mut resource_refs = indexmap::IndexSet::new();
279        
280        for release in &request.releases {
281            if let Some(ref_val) = &release.release_reference {
282                release_refs.insert(ref_val.clone());
283            }
284            
285            for track in &release.tracks {
286                if let Some(ref_val) = &track.resource_reference {
287                    resource_refs.insert(ref_val.clone());
288                }
289            }
290        }
291        
292        // Check deal references
293        for (idx, deal) in request.deals.iter().enumerate() {
294            for (r_idx, release_ref) in deal.release_references.iter().enumerate() {
295                if !release_refs.contains(release_ref) {
296                    result.errors.push(ValidationError {
297                        code: "UNKNOWN_REFERENCE".to_string(),
298                        field: "release_reference".to_string(),
299                        message: format!("Unknown release reference: {}", release_ref),
300                        location: format!("/deals[{}]/release_references[{}]", idx, r_idx),
301                    });
302                }
303            }
304        }
305        
306        Ok(())
307    }
308    
309    fn validate_profile(
310        &self,
311        request: &super::builder::BuildRequest,
312        profile: &str,
313        result: &mut ValidationResult,
314    ) -> Result<(), super::error::BuildError> {
315        match profile {
316            "AudioAlbum" => self.validate_audio_album_profile(request, result),
317            "AudioSingle" => self.validate_audio_single_profile(request, result),
318            _ => {
319                result.info.push(ValidationInfo {
320                    code: "UNKNOWN_PROFILE".to_string(),
321                    message: format!("Profile '{}' validation not implemented", profile),
322                });
323                Ok(())
324            }
325        }
326    }
327    
328    fn validate_audio_album_profile(
329        &self,
330        request: &super::builder::BuildRequest,
331        result: &mut ValidationResult,
332    ) -> Result<(), super::error::BuildError> {
333        // AudioAlbum specific requirements
334        for (idx, release) in request.releases.iter().enumerate() {
335            // Must have at least 2 tracks for album
336            if release.tracks.len() < 2 {
337                result.warnings.push(ValidationWarning {
338                    code: "ALBUM_TRACK_COUNT".to_string(),
339                    field: "tracks".to_string(),
340                    message: format!("AudioAlbum typically has 2+ tracks, found {}", 
341                        release.tracks.len()),
342                    location: format!("/releases[{}]/tracks", idx),
343                    suggestion: Some("Consider using AudioSingle profile".to_string()),
344                });
345            }
346            
347            // Should have UPC
348            if release.upc.is_none() {
349                result.errors.push(ValidationError {
350                    code: "MISSING_UPC".to_string(),
351                    field: "upc".to_string(),
352                    message: "UPC is required for AudioAlbum profile".to_string(),
353                    location: format!("/releases[{}]/upc", idx),
354                });
355            }
356        }
357        
358        Ok(())
359    }
360    
361    fn validate_audio_single_profile(
362        &self,
363        request: &super::builder::BuildRequest,
364        result: &mut ValidationResult,
365    ) -> Result<(), super::error::BuildError> {
366        // AudioSingle specific requirements
367        for (idx, release) in request.releases.iter().enumerate() {
368            // Should have 1-3 tracks for single
369            if release.tracks.len() > 3 {
370                result.warnings.push(ValidationWarning {
371                    code: "SINGLE_TRACK_COUNT".to_string(),
372                    field: "tracks".to_string(),
373                    message: format!("AudioSingle typically has 1-3 tracks, found {}", 
374                        release.tracks.len()),
375                    location: format!("/releases[{}]/tracks", idx),
376                    suggestion: Some("Consider using AudioAlbum profile".to_string()),
377                });
378            }
379        }
380        
381        Ok(())
382    }
383    
384    // Identifier validation methods
385    fn validate_isrc(&self, isrc: &str) -> bool {
386        ISRC_PATTERN.is_match(isrc)
387    }
388    
389    fn validate_upc(&self, upc: &str) -> bool {
390        if !UPC_PATTERN.is_match(upc) {
391            return false;
392        }
393        
394        // Validate check digit
395        self.validate_upc_checksum(upc)
396    }
397    
398    fn validate_upc_checksum(&self, upc: &str) -> bool {
399        let digits: Vec<u32> = upc.chars()
400            .filter_map(|c| c.to_digit(10))
401            .collect();
402        
403        if digits.len() < 12 {
404            return false;
405        }
406        
407        let mut sum = 0;
408        for (i, &digit) in digits.iter().take(digits.len() - 1).enumerate() {
409            if i % 2 == 0 {
410                sum += digit;
411            } else {
412                sum += digit * 3;
413            }
414        }
415        
416        let check_digit = (10 - (sum % 10)) % 10;
417        digits[digits.len() - 1] == check_digit
418    }
419    
420    fn validate_duration(&self, duration: &str) -> bool {
421        // Basic ISO 8601 duration validation
422        duration.starts_with("PT") && 
423            (duration.contains('M') || duration.contains('S'))
424    }
425    
426    fn validate_territory_code(&self, code: &str) -> bool {
427        // Basic ISO 3166-1 alpha-2 validation
428        code.len() == 2 && code.chars().all(|c| c.is_ascii_uppercase())
429    }
430}