1use regex::Regex;
5use once_cell::sync::Lazy;
6use serde::{Deserialize, Serialize};
7
8static 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
25pub struct PreflightValidator {
27 config: ValidationConfig,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct ValidationConfig {
33 pub level: PreflightLevel,
35
36 pub validate_identifiers: bool,
38
39 pub validate_checksums: bool,
41
42 pub check_required_fields: bool,
44
45 pub validate_dates: bool,
47
48 pub validate_references: bool,
50
51 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,
73 Warn,
75 None,
77}
78
79#[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 pub fn new(config: ValidationConfig) -> Self {
114 Self { config }
115 }
116
117 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 for (idx, release) in request.releases.iter().enumerate() {
135 self.validate_release(release, idx, &mut result)?;
136 }
137
138 for (idx, deal) in request.deals.iter().enumerate() {
140 self.validate_deal(deal, idx, &mut result)?;
141 }
142
143 if self.config.validate_references {
145 self.validate_references(request, &mut result)?;
146 }
147
148 if let Some(profile) = &self.config.profile {
150 self.validate_profile(request, profile, &mut result)?;
151 }
152
153 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 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 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 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 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 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 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 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 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 for (idx, release) in request.releases.iter().enumerate() {
335 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 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 for (idx, release) in request.releases.iter().enumerate() {
368 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 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 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 duration.starts_with("PT") &&
423 (duration.contains('M') || duration.contains('S'))
424 }
425
426 fn validate_territory_code(&self, code: &str) -> bool {
427 code.len() == 2 && code.chars().all(|c| c.is_ascii_uppercase())
429 }
430}