Skip to main content

voirs_cli/
validation.rs

1//! Input validation utilities for VoiRS CLI
2//!
3//! This module provides comprehensive validation functions for user inputs,
4//! configuration values, and command parameters. It ensures data integrity
5//! and provides helpful error messages for invalid inputs.
6
7use anyhow::{bail, Context, Result};
8use std::path::{Path, PathBuf};
9
10/// Validation result with helpful error messages
11pub type ValidationResult<T> = Result<T>;
12
13/// Voice name validation
14pub struct VoiceValidator;
15
16impl VoiceValidator {
17    /// Validate voice name format
18    ///
19    /// Voice names must:
20    /// - Be 1-64 characters long
21    /// - Contain only alphanumeric characters, hyphens, and underscores
22    /// - Start with an alphabetic character
23    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    /// Validate language code (ISO 639-1 or 639-3 format)
50    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
67/// Audio parameter validation
68pub struct AudioValidator;
69
70impl AudioValidator {
71    /// Validate sample rate (must be positive and within reasonable range)
72    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        // Warn if not a standard rate
93        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    /// Validate speed factor (0.25 - 4.0)
106    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    /// Validate pitch adjustment (-12.0 to +12.0 semitones)
126    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    /// Validate volume level (0.0 - 2.0)
154    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
182/// File path validation
183pub struct PathValidator;
184
185impl PathValidator {
186    /// Validate input file exists and is readable
187    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        // Check if file is readable by attempting to get metadata
197        std::fs::metadata(path)
198            .with_context(|| format!("Cannot access file: {}", path.display()))?;
199
200        Ok(path.to_path_buf())
201    }
202
203    /// Validate output path is writable
204    pub fn validate_output_path(path: &Path) -> ValidationResult<PathBuf> {
205        // Check parent directory exists and is writable
206        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            // Try to check write permissions
216            #[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        // Warn if file already exists
229        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    /// Validate directory exists and is accessible
240    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    /// Validate and create output directory if it doesn't exist
253    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
266/// Text input validation
267pub struct TextValidator;
268
269impl TextValidator {
270    /// Validate text is not empty after trimming
271    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    /// Validate text length is within limits
279    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    /// Validate SSML structure (basic check)
291    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        // Check for balanced tags (simplified)
301        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
316/// Batch processing validation
317pub struct BatchValidator;
318
319impl BatchValidator {
320    /// Validate batch size is reasonable
321    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    /// Validate worker count
340    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
359/// Model validation
360pub struct ModelValidator;
361
362impl ModelValidator {
363    /// Validate model file exists and has correct extension
364    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        // Check extension
374        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    /// Validate model file size is reasonable
387    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        // Check minimum size (models should be at least 1KB)
395        if size_bytes < 1024 {
396            bail!(
397                "Model file is suspiciously small: {} bytes. May be corrupted.",
398                size_bytes
399            );
400        }
401
402        // Check maximum size if specified
403        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        // Warn if very large
414        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    /// Validate model type matches expected type
425    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, // Unknown type, allow any
445        };
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    /// Validate model directory structure
459    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        // Check for config files
469        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
483/// Configuration validation
484pub struct ConfigValidator;
485
486impl ConfigValidator {
487    /// Validate port number
488    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    /// Validate timeout duration (in seconds)
508    pub fn validate_timeout(timeout_secs: u64) -> ValidationResult<u64> {
509        const MAX_TIMEOUT: u64 = 3600; // 1 hour
510
511        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    /// Validate buffer size
527    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        // Check if power of 2
540        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        // Test valid model file
715        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        // Test invalid extension
720        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        // Test nonexistent file
725        assert!(
726            ModelValidator::validate_model_file(Path::new("/nonexistent/model.safetensors"))
727                .is_err()
728        );
729
730        // Cleanup
731        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        // Test reasonable size
741        std::fs::write(&model_file, vec![0u8; 10240]).unwrap(); // 10KB
742        assert!(ModelValidator::validate_model_size(&model_file, Some(100)).is_ok());
743
744        // Test too small
745        std::fs::write(&model_file, vec![0u8; 512]).unwrap(); // 512 bytes
746        assert!(ModelValidator::validate_model_size(&model_file, None).is_err());
747
748        // Cleanup
749        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        // Cleanup
765        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        // Cleanup
781        std::fs::remove_dir_all(&model_dir).ok();
782    }
783}