ddex_builder/
preflight.rs

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