onedrop_engine/
safe_loader.rs

1//! Safe preset loading with error recovery.
2
3use crate::engine::MilkEngine;
4use crate::error::{EngineError, Result};
5use std::path::Path;
6
7/// Safe preset loader with automatic fallback.
8pub struct SafePresetLoader;
9
10impl SafePresetLoader {
11    /// Try to load a preset, falling back to default on error.
12    ///
13    /// This method:
14    /// 1. Tries to load the requested preset
15    /// 2. On error, logs the issue and loads default preset
16    /// 3. Never fails (always returns Ok)
17    pub fn load_with_fallback<P: AsRef<Path>>(engine: &mut MilkEngine, path: P) -> Result<()> {
18        let path_ref = path.as_ref();
19
20        match engine.load_preset(path_ref) {
21            Ok(()) => {
22                log::info!("Successfully loaded preset: {}", path_ref.display());
23                Ok(())
24            }
25            Err(e) => {
26                log::error!(
27                    "Failed to load preset {}: {}. Loading default preset.",
28                    path_ref.display(),
29                    e
30                );
31
32                // Try to load default preset
33                match engine.load_default_preset() {
34                    Ok(()) => {
35                        log::info!("Successfully loaded default preset as fallback");
36                        Ok(())
37                    }
38                    Err(fallback_err) => {
39                        // This should never happen since default preset is hardcoded
40                        log::error!("CRITICAL: Failed to load default preset: {}", fallback_err);
41                        Err(EngineError::PresetLoadFailed(format!(
42                            "Both preset and fallback failed: {} / {}",
43                            e, fallback_err
44                        )))
45                    }
46                }
47            }
48        }
49    }
50
51    /// Try to load a preset, retrying on transient errors.
52    ///
53    /// # Arguments
54    /// * `engine` - The MilkEngine instance
55    /// * `path` - Path to the preset file
56    /// * `max_retries` - Maximum number of retry attempts
57    pub fn load_with_retry<P: AsRef<Path>>(
58        engine: &mut MilkEngine,
59        path: P,
60        max_retries: usize,
61    ) -> Result<()> {
62        let path_ref = path.as_ref();
63        let mut last_error = None;
64
65        for attempt in 0..=max_retries {
66            match engine.load_preset(path_ref) {
67                Ok(()) => {
68                    if attempt > 0 {
69                        log::info!(
70                            "Successfully loaded preset {} after {} retries",
71                            path_ref.display(),
72                            attempt
73                        );
74                    }
75                    return Ok(());
76                }
77                Err(e) => {
78                    log::warn!(
79                        "Attempt {}/{} failed to load preset {}: {}",
80                        attempt + 1,
81                        max_retries + 1,
82                        path_ref.display(),
83                        e
84                    );
85                    last_error = Some(e);
86
87                    // Wait a bit before retrying (exponential backoff)
88                    if attempt < max_retries {
89                        let wait_ms = 100 * (1 << attempt); // 100ms, 200ms, 400ms, ...
90                        std::thread::sleep(std::time::Duration::from_millis(wait_ms));
91                    }
92                }
93            }
94        }
95
96        // All retries failed, fall back to default
97        log::error!(
98            "All {} attempts failed for preset {}. Loading default preset.",
99            max_retries + 1,
100            path_ref.display()
101        );
102
103        engine.load_default_preset().map_err(|fallback_err| {
104            EngineError::PresetLoadFailed(format!(
105                "Preset loading failed after {} retries, and fallback also failed: {} / {}",
106                max_retries,
107                last_error
108                    .map(|e| e.to_string())
109                    .unwrap_or_else(|| "Unknown".to_string()),
110                fallback_err
111            ))
112        })
113    }
114
115    /// Validate a preset file without loading it.
116    ///
117    /// Returns Ok(()) if the preset is valid, Err otherwise.
118    pub fn validate_preset<P: AsRef<Path>>(path: P) -> Result<()> {
119        let path_ref = path.as_ref();
120
121        // Check file exists
122        if !path_ref.exists() {
123            return Err(EngineError::PresetLoadFailed(format!(
124                "File does not exist: {}",
125                path_ref.display()
126            )));
127        }
128
129        // Check file is readable (UTF-8 lossy — see engine.rs)
130        let bytes = std::fs::read(path_ref)?;
131        let content = String::from_utf8_lossy(&bytes).into_owned();
132
133        // Try to parse
134        onedrop_parser::parse_preset(&content)?;
135
136        log::debug!("Preset {} is valid", path_ref.display());
137        Ok(())
138    }
139
140    /// Scan a directory for valid presets.
141    ///
142    /// Returns a list of paths to valid preset files.
143    pub fn scan_directory<P: AsRef<Path>>(dir: P) -> Vec<std::path::PathBuf> {
144        let dir_ref = dir.as_ref();
145        let mut valid_presets = Vec::new();
146
147        if let Ok(entries) = std::fs::read_dir(dir_ref) {
148            for entry in entries.flatten() {
149                let path = entry.path();
150
151                // Check if it's a .milk file
152                if path.extension().and_then(|s| s.to_str()) == Some("milk") {
153                    // Validate the preset
154                    if Self::validate_preset(&path).is_ok() {
155                        valid_presets.push(path);
156                    } else {
157                        log::warn!("Skipping invalid preset: {}", path.display());
158                    }
159                }
160            }
161        }
162
163        log::info!(
164            "Found {} valid presets in {}",
165            valid_presets.len(),
166            dir_ref.display()
167        );
168        valid_presets
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use crate::engine::EngineConfig;
176
177    #[test]
178    fn test_load_default_preset() {
179        let config = EngineConfig::default();
180        let mut engine = pollster::block_on(MilkEngine::new(config)).unwrap();
181
182        // Should be able to load default preset
183        let result = engine.load_default_preset();
184        assert!(result.is_ok());
185    }
186
187    #[test]
188    fn test_validate_preset() {
189        // Invalid path should fail
190        let result = SafePresetLoader::validate_preset("nonexistent.milk");
191        assert!(result.is_err());
192    }
193}