1use once_cell::sync::Lazy;
5use regex::Regex;
6use serde::{Deserialize, Serialize};
7
8static 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
20pub struct PreflightValidator {
22 config: ValidationConfig,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct ValidationConfig {
28 pub level: PreflightLevel,
30
31 pub validate_identifiers: bool,
33
34 pub validate_checksums: bool,
36
37 pub check_required_fields: bool,
39
40 pub validate_dates: bool,
42
43 pub validate_references: bool,
45
46 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
66pub enum PreflightLevel {
67 Strict,
69 Warn,
71 None,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct ValidationResult {
78 pub errors: Vec<ValidationError>,
80 pub warnings: Vec<ValidationWarning>,
82 pub info: Vec<ValidationInfo>,
84 pub passed: bool,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct ValidationError {
91 pub code: String,
93 pub field: String,
95 pub message: String,
97 pub location: String,
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct ValidationWarning {
104 pub code: String,
106 pub field: String,
108 pub message: String,
110 pub location: String,
112 pub suggestion: Option<String>,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct ValidationInfo {
119 pub code: String,
121 pub message: String,
123}
124
125impl PreflightValidator {
126 pub fn new(config: ValidationConfig) -> Self {
128 Self { config }
129 }
130
131 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 for (idx, release) in request.releases.iter().enumerate() {
149 self.validate_release(release, idx, &mut result)?;
150 }
151
152 for (idx, deal) in request.deals.iter().enumerate() {
154 self.validate_deal(deal, idx, &mut result)?;
155 }
156
157 if self.config.validate_references {
159 self.validate_references(request, &mut result)?;
160 }
161
162 if let Some(profile) = &self.config.profile {
164 self.validate_profile(request, profile, &mut result)?;
165 }
166
167 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 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 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 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 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 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 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 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 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 for (idx, release) in request.releases.iter().enumerate() {
349 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 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 for (idx, release) in request.releases.iter().enumerate() {
384 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 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 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 duration.starts_with("PT") && (duration.contains('M') || duration.contains('S'))
439 }
440
441 fn validate_territory_code(&self, code: &str) -> bool {
442 code.len() == 2 && code.chars().all(|c| c.is_ascii_uppercase())
444 }
445}