1use anyhow::{bail, Context, Result};
8use std::path::{Path, PathBuf};
9
10pub type ValidationResult<T> = Result<T>;
12
13pub struct VoiceValidator;
15
16impl VoiceValidator {
17 pub fn validate_name(name: &str) -> ValidationResult<()> {
24 if name.is_empty() {
25 bail!("Voice name cannot be empty");
26 }
27
28 if name.len() > 64 {
29 bail!("Voice name too long (max 64 characters): {}", name.len());
30 }
31
32 if !name.chars().next().unwrap().is_alphabetic() {
33 bail!("Voice name must start with a letter: '{}'", name);
34 }
35
36 if !name
37 .chars()
38 .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
39 {
40 bail!(
41 "Voice name contains invalid characters: '{}'.\nAllowed: letters, numbers, hyphens, underscores",
42 name
43 );
44 }
45
46 Ok(())
47 }
48
49 pub fn validate_language(lang: &str) -> ValidationResult<()> {
51 let len = lang.len();
52 if len != 2 && len != 3 {
53 bail!(
54 "Invalid language code '{}'. Expected 2-letter (ISO 639-1) or 3-letter (ISO 639-3) code",
55 lang
56 );
57 }
58
59 if !lang.chars().all(|c| c.is_ascii_lowercase()) {
60 bail!("Language code must be lowercase: '{}'", lang);
61 }
62
63 Ok(())
64 }
65}
66
67pub struct AudioValidator;
69
70impl AudioValidator {
71 pub fn validate_sample_rate(rate: u32) -> ValidationResult<u32> {
73 const MIN_RATE: u32 = 8000;
74 const MAX_RATE: u32 = 192000;
75
76 if rate < MIN_RATE {
77 bail!(
78 "Sample rate too low: {} Hz (minimum: {} Hz)",
79 rate,
80 MIN_RATE
81 );
82 }
83
84 if rate > MAX_RATE {
85 bail!(
86 "Sample rate too high: {} Hz (maximum: {} Hz)",
87 rate,
88 MAX_RATE
89 );
90 }
91
92 const STANDARD_RATES: &[u32] = &[8000, 11025, 16000, 22050, 44100, 48000, 96000, 192000];
94 if !STANDARD_RATES.contains(&rate) {
95 tracing::warn!(
96 "Non-standard sample rate: {} Hz. Standard rates: {:?}",
97 rate,
98 STANDARD_RATES
99 );
100 }
101
102 Ok(rate)
103 }
104
105 pub fn validate_speed(speed: f32) -> ValidationResult<f32> {
107 const MIN_SPEED: f32 = 0.25;
108 const MAX_SPEED: f32 = 4.0;
109
110 if !speed.is_finite() {
111 bail!("Speed must be a finite number");
112 }
113
114 if speed < MIN_SPEED {
115 bail!("Speed too low: {:.2} (minimum: {:.2})", speed, MIN_SPEED);
116 }
117
118 if speed > MAX_SPEED {
119 bail!("Speed too high: {:.2} (maximum: {:.2})", speed, MAX_SPEED);
120 }
121
122 Ok(speed)
123 }
124
125 pub fn validate_pitch(pitch: f32) -> ValidationResult<f32> {
127 const MIN_PITCH: f32 = -12.0;
128 const MAX_PITCH: f32 = 12.0;
129
130 if !pitch.is_finite() {
131 bail!("Pitch must be a finite number");
132 }
133
134 if pitch < MIN_PITCH {
135 bail!(
136 "Pitch too low: {:.1} semitones (minimum: {:.1})",
137 pitch,
138 MIN_PITCH
139 );
140 }
141
142 if pitch > MAX_PITCH {
143 bail!(
144 "Pitch too high: {:.1} semitones (maximum: {:.1})",
145 pitch,
146 MAX_PITCH
147 );
148 }
149
150 Ok(pitch)
151 }
152
153 pub fn validate_volume(volume: f32) -> ValidationResult<f32> {
155 const MIN_VOLUME: f32 = 0.0;
156 const MAX_VOLUME: f32 = 2.0;
157
158 if !volume.is_finite() {
159 bail!("Volume must be a finite number");
160 }
161
162 if volume < MIN_VOLUME {
163 bail!("Volume cannot be negative: {:.2}", volume);
164 }
165
166 if volume > MAX_VOLUME {
167 bail!(
168 "Volume too high: {:.2} (maximum: {:.2})",
169 volume,
170 MAX_VOLUME
171 );
172 }
173
174 if volume > 1.0 {
175 tracing::warn!("Volume above 1.0 may cause clipping: {:.2}", volume);
176 }
177
178 Ok(volume)
179 }
180}
181
182pub struct PathValidator;
184
185impl PathValidator {
186 pub fn validate_input_file(path: &Path) -> ValidationResult<PathBuf> {
188 if !path.exists() {
189 bail!("Input file not found: {}", path.display());
190 }
191
192 if !path.is_file() {
193 bail!("Path is not a file: {}", path.display());
194 }
195
196 std::fs::metadata(path)
198 .with_context(|| format!("Cannot access file: {}", path.display()))?;
199
200 Ok(path.to_path_buf())
201 }
202
203 pub fn validate_output_path(path: &Path) -> ValidationResult<PathBuf> {
205 if let Some(parent) = path.parent() {
207 if !parent.exists() {
208 bail!("Output directory does not exist: {}", parent.display());
209 }
210
211 if !parent.is_dir() {
212 bail!("Output parent is not a directory: {}", parent.display());
213 }
214
215 #[cfg(unix)]
217 {
218 use std::os::unix::fs::PermissionsExt;
219 let metadata = std::fs::metadata(parent)
220 .with_context(|| format!("Cannot access directory: {}", parent.display()))?;
221 let permissions = metadata.permissions();
222 if permissions.mode() & 0o200 == 0 {
223 bail!("Output directory is not writable: {}", parent.display());
224 }
225 }
226 }
227
228 if path.exists() {
230 tracing::warn!(
231 "Output file already exists and will be overwritten: {}",
232 path.display()
233 );
234 }
235
236 Ok(path.to_path_buf())
237 }
238
239 pub fn validate_directory(path: &Path) -> ValidationResult<PathBuf> {
241 if !path.exists() {
242 bail!("Directory not found: {}", path.display());
243 }
244
245 if !path.is_dir() {
246 bail!("Path is not a directory: {}", path.display());
247 }
248
249 Ok(path.to_path_buf())
250 }
251
252 pub fn ensure_output_directory(path: &Path) -> ValidationResult<PathBuf> {
254 if !path.exists() {
255 std::fs::create_dir_all(path)
256 .with_context(|| format!("Failed to create directory: {}", path.display()))?;
257 tracing::info!("Created output directory: {}", path.display());
258 } else if !path.is_dir() {
259 bail!("Path exists but is not a directory: {}", path.display());
260 }
261
262 Ok(path.to_path_buf())
263 }
264}
265
266pub struct TextValidator;
268
269impl TextValidator {
270 pub fn validate_non_empty(text: &str) -> ValidationResult<()> {
272 if text.trim().is_empty() {
273 bail!("Text input cannot be empty");
274 }
275 Ok(())
276 }
277
278 pub fn validate_length(text: &str, max_len: usize) -> ValidationResult<()> {
280 if text.len() > max_len {
281 bail!(
282 "Text too long: {} characters (maximum: {})",
283 text.len(),
284 max_len
285 );
286 }
287 Ok(())
288 }
289
290 pub fn validate_ssml_basic(text: &str) -> ValidationResult<()> {
292 if !text.contains("<speak>") {
293 bail!("SSML must contain <speak> root element");
294 }
295
296 if !text.contains("</speak>") {
297 bail!("SSML <speak> element is not closed");
298 }
299
300 let open_count = text.matches('<').count();
302 let close_count = text.matches('>').count();
303
304 if open_count != close_count {
305 bail!(
306 "Unbalanced XML tags in SSML (found {} '<' and {} '>')",
307 open_count,
308 close_count
309 );
310 }
311
312 Ok(())
313 }
314}
315
316pub struct BatchValidator;
318
319impl BatchValidator {
320 pub fn validate_batch_size(size: usize) -> ValidationResult<usize> {
322 const MAX_BATCH_SIZE: usize = 10000;
323
324 if size == 0 {
325 bail!("Batch size must be greater than 0");
326 }
327
328 if size > MAX_BATCH_SIZE {
329 bail!(
330 "Batch size too large: {} (maximum: {})",
331 size,
332 MAX_BATCH_SIZE
333 );
334 }
335
336 Ok(size)
337 }
338
339 pub fn validate_workers(workers: usize) -> ValidationResult<usize> {
341 let max_workers = num_cpus::get() * 2;
342
343 if workers == 0 {
344 bail!("Worker count must be greater than 0");
345 }
346
347 if workers > max_workers {
348 tracing::warn!(
349 "Worker count {} exceeds recommended maximum {} (2x CPU cores)",
350 workers,
351 max_workers
352 );
353 }
354
355 Ok(workers)
356 }
357}
358
359pub struct ModelValidator;
361
362impl ModelValidator {
363 pub fn validate_model_file(path: &Path) -> ValidationResult<()> {
365 if !path.exists() {
366 bail!("Model file not found: {}", path.display());
367 }
368
369 if !path.is_file() {
370 bail!("Path is not a file: {}", path.display());
371 }
372
373 let ext = path.extension().and_then(|e| e.to_str());
375 match ext {
376 Some("safetensors") | Some("st") | Some("pt") | Some("pth") | Some("bin")
377 | Some("onnx") => Ok(()),
378 Some(e) => bail!(
379 "Unsupported model format: '{}'. Supported: safetensors, pt, pth, bin, onnx",
380 e
381 ),
382 None => bail!("Model file has no extension: {}", path.display()),
383 }
384 }
385
386 pub fn validate_model_size(path: &Path, max_size_mb: Option<u64>) -> ValidationResult<u64> {
388 let metadata = std::fs::metadata(path)
389 .with_context(|| format!("Cannot access file: {}", path.display()))?;
390
391 let size_bytes = metadata.len();
392 let size_mb = size_bytes / 1_048_576;
393
394 if size_bytes < 1024 {
396 bail!(
397 "Model file is suspiciously small: {} bytes. May be corrupted.",
398 size_bytes
399 );
400 }
401
402 if let Some(max_mb) = max_size_mb {
404 if size_mb > max_mb {
405 bail!(
406 "Model file too large: {} MB (maximum: {} MB)",
407 size_mb,
408 max_mb
409 );
410 }
411 }
412
413 if size_mb > 1000 {
415 tracing::warn!(
416 "Model file is very large: {} MB. Loading may take significant time.",
417 size_mb
418 );
419 }
420
421 Ok(size_bytes)
422 }
423
424 pub fn validate_model_type(model_path: &Path, expected_type: &str) -> ValidationResult<()> {
426 let filename = model_path
427 .file_name()
428 .and_then(|n| n.to_str())
429 .ok_or_else(|| anyhow::anyhow!("Invalid filename"))?
430 .to_lowercase();
431
432 let matches = match expected_type {
433 "vocoder" => {
434 filename.contains("vocoder")
435 || filename.contains("hifigan")
436 || filename.contains("diffwave")
437 }
438 "acoustic" => {
439 filename.contains("acoustic")
440 || filename.contains("vits")
441 || filename.contains("fastspeech")
442 }
443 "g2p" => filename.contains("g2p") || filename.contains("phoneme"),
444 _ => true, };
446
447 if !matches {
448 tracing::warn!(
449 "Model filename '{}' may not match expected type '{}'",
450 filename,
451 expected_type
452 );
453 }
454
455 Ok(())
456 }
457
458 pub fn validate_model_directory(path: &Path) -> ValidationResult<()> {
460 if !path.exists() {
461 bail!("Model directory not found: {}", path.display());
462 }
463
464 if !path.is_dir() {
465 bail!("Path is not a directory: {}", path.display());
466 }
467
468 let config_json = path.join("config.json");
470 let has_config = config_json.exists();
471
472 if !has_config {
473 tracing::warn!(
474 "No config.json found in model directory: {}",
475 path.display()
476 );
477 }
478
479 Ok(())
480 }
481}
482
483pub struct ConfigValidator;
485
486impl ConfigValidator {
487 pub fn validate_port(port: u16) -> ValidationResult<u16> {
489 const MIN_PORT: u16 = 1024;
490 const MAX_PORT: u16 = 65535;
491
492 if port < MIN_PORT {
493 bail!(
494 "Port number too low: {} (minimum: {} to avoid privileged ports)",
495 port,
496 MIN_PORT
497 );
498 }
499
500 if port > MAX_PORT {
501 bail!("Port number too high: {} (maximum: {})", port, MAX_PORT);
502 }
503
504 Ok(port)
505 }
506
507 pub fn validate_timeout(timeout_secs: u64) -> ValidationResult<u64> {
509 const MAX_TIMEOUT: u64 = 3600; if timeout_secs == 0 {
512 bail!("Timeout must be greater than 0");
513 }
514
515 if timeout_secs > MAX_TIMEOUT {
516 bail!(
517 "Timeout too long: {} seconds (maximum: {} seconds / 1 hour)",
518 timeout_secs,
519 MAX_TIMEOUT
520 );
521 }
522
523 Ok(timeout_secs)
524 }
525
526 pub fn validate_buffer_size(size: usize) -> ValidationResult<usize> {
528 const MIN_BUFFER: usize = 64;
529 const MAX_BUFFER: usize = 8192;
530
531 if size < MIN_BUFFER {
532 bail!("Buffer size too small: {} (minimum: {})", size, MIN_BUFFER);
533 }
534
535 if size > MAX_BUFFER {
536 bail!("Buffer size too large: {} (maximum: {})", size, MAX_BUFFER);
537 }
538
539 if size & (size - 1) != 0 {
541 tracing::warn!(
542 "Buffer size {} is not a power of 2, may affect performance",
543 size
544 );
545 }
546
547 Ok(size)
548 }
549}
550
551#[cfg(test)]
552mod tests {
553 use super::*;
554 use std::env;
555
556 #[test]
557 fn test_voice_name_validation() {
558 assert!(VoiceValidator::validate_name("myvoice").is_ok());
559 assert!(VoiceValidator::validate_name("my_voice").is_ok());
560 assert!(VoiceValidator::validate_name("my-voice-123").is_ok());
561
562 assert!(VoiceValidator::validate_name("").is_err());
563 assert!(VoiceValidator::validate_name("123voice").is_err());
564 assert!(VoiceValidator::validate_name("my voice").is_err());
565 assert!(VoiceValidator::validate_name("my.voice").is_err());
566 }
567
568 #[test]
569 fn test_language_validation() {
570 assert!(VoiceValidator::validate_language("en").is_ok());
571 assert!(VoiceValidator::validate_language("ja").is_ok());
572 assert!(VoiceValidator::validate_language("eng").is_ok());
573
574 assert!(VoiceValidator::validate_language("EN").is_err());
575 assert!(VoiceValidator::validate_language("e").is_err());
576 assert!(VoiceValidator::validate_language("english").is_err());
577 }
578
579 #[test]
580 fn test_sample_rate_validation() {
581 assert!(AudioValidator::validate_sample_rate(44100).is_ok());
582 assert!(AudioValidator::validate_sample_rate(48000).is_ok());
583
584 assert!(AudioValidator::validate_sample_rate(1000).is_err());
585 assert!(AudioValidator::validate_sample_rate(300000).is_err());
586 }
587
588 #[test]
589 fn test_speed_validation() {
590 assert!(AudioValidator::validate_speed(1.0).is_ok());
591 assert!(AudioValidator::validate_speed(0.5).is_ok());
592 assert!(AudioValidator::validate_speed(2.0).is_ok());
593
594 assert!(AudioValidator::validate_speed(0.1).is_err());
595 assert!(AudioValidator::validate_speed(5.0).is_err());
596 assert!(AudioValidator::validate_speed(f32::NAN).is_err());
597 assert!(AudioValidator::validate_speed(f32::INFINITY).is_err());
598 }
599
600 #[test]
601 fn test_pitch_validation() {
602 assert!(AudioValidator::validate_pitch(0.0).is_ok());
603 assert!(AudioValidator::validate_pitch(6.0).is_ok());
604 assert!(AudioValidator::validate_pitch(-6.0).is_ok());
605
606 assert!(AudioValidator::validate_pitch(15.0).is_err());
607 assert!(AudioValidator::validate_pitch(-15.0).is_err());
608 }
609
610 #[test]
611 fn test_volume_validation() {
612 assert!(AudioValidator::validate_volume(1.0).is_ok());
613 assert!(AudioValidator::validate_volume(0.5).is_ok());
614
615 assert!(AudioValidator::validate_volume(-0.1).is_err());
616 assert!(AudioValidator::validate_volume(3.0).is_err());
617 }
618
619 #[test]
620 fn test_text_validation() {
621 assert!(TextValidator::validate_non_empty("Hello").is_ok());
622 assert!(TextValidator::validate_non_empty(" Hello ").is_ok());
623
624 assert!(TextValidator::validate_non_empty("").is_err());
625 assert!(TextValidator::validate_non_empty(" ").is_err());
626 }
627
628 #[test]
629 fn test_text_length_validation() {
630 assert!(TextValidator::validate_length("Hello", 10).is_ok());
631 assert!(TextValidator::validate_length("Hello", 5).is_ok());
632
633 assert!(TextValidator::validate_length("Hello World", 5).is_err());
634 }
635
636 #[test]
637 fn test_ssml_validation() {
638 assert!(TextValidator::validate_ssml_basic("<speak>Hello</speak>").is_ok());
639
640 assert!(TextValidator::validate_ssml_basic("Hello").is_err());
641 assert!(TextValidator::validate_ssml_basic("<speak>Hello").is_err());
642 assert!(TextValidator::validate_ssml_basic("<speak>Hello<").is_err());
643 }
644
645 #[test]
646 fn test_batch_size_validation() {
647 assert!(BatchValidator::validate_batch_size(100).is_ok());
648 assert!(BatchValidator::validate_batch_size(1).is_ok());
649
650 assert!(BatchValidator::validate_batch_size(0).is_err());
651 assert!(BatchValidator::validate_batch_size(20000).is_err());
652 }
653
654 #[test]
655 fn test_worker_validation() {
656 assert!(BatchValidator::validate_workers(1).is_ok());
657 assert!(BatchValidator::validate_workers(4).is_ok());
658
659 assert!(BatchValidator::validate_workers(0).is_err());
660 }
661
662 #[test]
663 fn test_port_validation() {
664 assert!(ConfigValidator::validate_port(8080).is_ok());
665 assert!(ConfigValidator::validate_port(3000).is_ok());
666 assert!(ConfigValidator::validate_port(65535).is_ok());
667
668 assert!(ConfigValidator::validate_port(80).is_err());
669 assert!(ConfigValidator::validate_port(1000).is_err());
670 }
671
672 #[test]
673 fn test_timeout_validation() {
674 assert!(ConfigValidator::validate_timeout(30).is_ok());
675 assert!(ConfigValidator::validate_timeout(300).is_ok());
676
677 assert!(ConfigValidator::validate_timeout(0).is_err());
678 assert!(ConfigValidator::validate_timeout(5000).is_err());
679 }
680
681 #[test]
682 fn test_buffer_size_validation() {
683 assert!(ConfigValidator::validate_buffer_size(512).is_ok());
684 assert!(ConfigValidator::validate_buffer_size(1024).is_ok());
685
686 assert!(ConfigValidator::validate_buffer_size(32).is_err());
687 assert!(ConfigValidator::validate_buffer_size(10000).is_err());
688 }
689
690 #[test]
691 fn test_input_file_validation() {
692 let temp_dir = env::temp_dir();
693 let test_file = temp_dir.join("test_input.txt");
694 std::fs::write(&test_file, "test").unwrap();
695
696 assert!(PathValidator::validate_input_file(&test_file).is_ok());
697 assert!(PathValidator::validate_input_file(Path::new("/nonexistent/file.txt")).is_err());
698
699 std::fs::remove_file(&test_file).ok();
700 }
701
702 #[test]
703 fn test_directory_validation() {
704 let temp_dir = env::temp_dir();
705
706 assert!(PathValidator::validate_directory(&temp_dir).is_ok());
707 assert!(PathValidator::validate_directory(Path::new("/nonexistent/dir")).is_err());
708 }
709
710 #[test]
711 fn test_model_file_validation() {
712 let temp_dir = env::temp_dir();
713
714 let valid_model = temp_dir.join("test_model_file_validation.safetensors");
716 std::fs::write(&valid_model, "test").unwrap();
717 assert!(ModelValidator::validate_model_file(&valid_model).is_ok());
718
719 let invalid_model = temp_dir.join("test_model.txt");
721 std::fs::write(&invalid_model, "test").unwrap();
722 assert!(ModelValidator::validate_model_file(&invalid_model).is_err());
723
724 assert!(
726 ModelValidator::validate_model_file(Path::new("/nonexistent/model.safetensors"))
727 .is_err()
728 );
729
730 std::fs::remove_file(&valid_model).ok();
732 std::fs::remove_file(&invalid_model).ok();
733 }
734
735 #[test]
736 fn test_model_size_validation() {
737 let temp_dir = env::temp_dir();
738 let model_file = temp_dir.join("test_model_size_validation.safetensors");
739
740 std::fs::write(&model_file, vec![0u8; 10240]).unwrap(); assert!(ModelValidator::validate_model_size(&model_file, Some(100)).is_ok());
743
744 std::fs::write(&model_file, vec![0u8; 512]).unwrap(); assert!(ModelValidator::validate_model_size(&model_file, None).is_err());
747
748 std::fs::remove_file(&model_file).ok();
750 }
751
752 #[test]
753 fn test_model_type_validation() {
754 let temp_dir = env::temp_dir();
755
756 let vocoder_model = temp_dir.join("hifigan_vocoder.safetensors");
757 std::fs::write(&vocoder_model, "test").unwrap();
758 assert!(ModelValidator::validate_model_type(&vocoder_model, "vocoder").is_ok());
759
760 let acoustic_model = temp_dir.join("vits_acoustic.safetensors");
761 std::fs::write(&acoustic_model, "test").unwrap();
762 assert!(ModelValidator::validate_model_type(&acoustic_model, "acoustic").is_ok());
763
764 std::fs::remove_file(&vocoder_model).ok();
766 std::fs::remove_file(&acoustic_model).ok();
767 }
768
769 #[test]
770 fn test_model_directory_validation() {
771 let temp_dir = env::temp_dir();
772 let model_dir = temp_dir.join("test_model_dir");
773 std::fs::create_dir_all(&model_dir).unwrap();
774
775 assert!(ModelValidator::validate_model_directory(&model_dir).is_ok());
776 assert!(
777 ModelValidator::validate_model_directory(Path::new("/nonexistent/model_dir")).is_err()
778 );
779
780 std::fs::remove_dir_all(&model_dir).ok();
782 }
783}