Skip to main content

rustriff_lib/services/
file_service.rs

1use crate::domain::dto::effect::ir_profile_dto::IrProfileDto;
2use crate::domain::validation::sanitize_wav_file_name;
3use crate::infrastructure::file_loader::FileLoaderTrait;
4use std::path::PathBuf;
5use tracing::{info, warn};
6
7const DEFAULT_IR_DIRECTORY_NAME: &str = "default_ir";
8const RESOURCES_DIRECTORY_NAME: &str = "resources";
9/// Minimum absolute value a sample in the first 256 samples of an IR must have
10/// to be considered a valid impulse.  Files that fail this check are almost
11/// certainly not IRs (e.g., silence, or music recordings).
12const IMPULSE_THRESHOLD: f32 = 1e-6;
13
14/// Application service for IR profile discovery, upload, and removal.
15///
16/// `FileService` is the single authoritative source of truth for which IR
17/// profiles the application knows about.  It merges the read-only
18/// **default** IR set (shipped inside the Tauri bundle) with the
19/// user-managed **custom** IR set stored in a writable directory.
20///
21/// ## Thread safety
22/// `FileService` holds a `Box<dyn FileLoaderTrait>` which is `Send + Sync`,
23/// making `FileService` itself safe to place in Tauri's shared-state container.
24pub struct FileService {
25    file_loader: Box<dyn FileLoaderTrait>,
26    /// Root directory from which `resource_root/default_ir` is resolved in
27    /// release builds.  In debug builds, `CARGO_MANIFEST_DIR` is used instead
28    /// so running `cargo tauri dev` works without installing the app.
29    resource_root: PathBuf,
30    /// Writable directory where user-uploaded custom IR files are stored.
31    custom_ir_directory: PathBuf,
32}
33
34impl FileService {
35    /// Creates a new `FileService`.
36    /// - `file_loader` – production or test-double implementation.
37    /// - `resource_root` – used in release builds to locate bundled resources.
38    /// - `custom_ir_directory` – path to the user's custom IR storage folder.
39    pub fn new(
40        file_loader: Box<dyn FileLoaderTrait>,
41        resource_root: PathBuf,
42        custom_ir_directory: PathBuf,
43    ) -> Self {
44        Self {
45            file_loader,
46            resource_root,
47            custom_ir_directory,
48        }
49    }
50
51    /// Returns all available IR profiles, merging default and custom sets.
52    ///
53    /// For each profile the [`IrProfileDto::is_in_use`] flag is **not** populated
54    /// here (it is always `false`).  Callers that need accurate `is_in_use` values
55    /// must cross-reference against the running effect chains — this is done by the
56    /// [`get_all_ir_profiles`] Tauri command.
57    ///
58    /// The returned list is sorted alphabetically by [`IrProfileDto::label`].
59    /// ## Errors
60    /// Propagates errors from [`FileLoaderTrait::list_ir_profile_file_names`] or
61    /// [`FileLoaderTrait::ensure_directory`] if the filesystem is inaccessible.
62    /// [`get_all_ir_profiles`]: crate::commands::effect_commands::cabinet_ir::get_all_ir_profiles
63    pub fn get_all_ir_profiles(&self) -> Result<Vec<IrProfileDto>, String> {
64        let default_directory = self.resolve_default_ir_directory()?;
65        self.file_loader
66            .ensure_directory(&self.custom_ir_directory)?;
67
68        let mut profiles = self
69            .file_loader
70            .list_ir_profile_file_names(&default_directory)?
71            .into_iter()
72            .map(|file_name| IrProfileDto {
73                label: to_readable_label(&file_name),
74                file_name,
75                is_custom: false,
76                is_in_use: false,
77            })
78            .collect::<Vec<_>>();
79
80        let custom_profiles = self
81            .file_loader
82            .list_ir_profile_file_names(&self.custom_ir_directory)?
83            .into_iter()
84            .map(|file_name| IrProfileDto {
85                label: to_readable_label(&file_name),
86                file_name,
87                is_custom: true,
88                is_in_use: false,
89            });
90
91        profiles.extend(custom_profiles);
92        profiles.sort_by(|a, b| a.label.cmp(&b.label));
93        Ok(profiles)
94    }
95
96    /// Validates and persists a user-uploaded custom IR file.
97    /// The following checks are applied before writing:
98    /// 1. `file_name` is sanitized (no path traversal, `.wav` extension required).
99    /// 2. `file_bytes` are validated as a well-formed WAV containing an audible
100    ///    impulse via [`FileLoaderTrait::validate_ir_wav_bytes`].
101    /// 3. A file with the same name must not already exist in the default IR set —
102    ///    uploading a custom profile that would shadow a default one is rejected.
103    ///
104    /// On success, the sanitized file name (which may differ from `file_name` only
105    /// in surrounding whitespace) is returned so the caller can display it.
106    ///
107    /// ## Errors
108    /// Returns `Err` if any validation step fails or the file cannot be written.
109    pub fn save_custom_ir_profile(
110        &self,
111        file_name: &str,
112        file_bytes: &[u8],
113    ) -> Result<String, String> {
114        let sanitized_file_name = sanitize_wav_file_name(file_name)?;
115
116        self.file_loader.validate_ir_wav_bytes(
117            &sanitized_file_name,
118            file_bytes,
119            IMPULSE_THRESHOLD,
120        )?;
121
122        let default_directory = self.resolve_default_ir_directory()?;
123        let default_path = default_directory.join(&sanitized_file_name);
124        if default_path.exists() {
125            return Err(format!(
126                "An IR named '{}' already exists in defaults",
127                sanitized_file_name
128            ));
129        }
130
131        self.file_loader
132            .ensure_directory(&self.custom_ir_directory)?;
133        let custom_path = self.custom_ir_directory.join(&sanitized_file_name);
134        self.file_loader
135            .write_file_bytes(&custom_path, file_bytes)?;
136
137        Ok(sanitized_file_name)
138    }
139
140    /// Removes a user-uploaded custom IR file from the custom IR directory.
141    ///
142    /// `file_name` is sanitized before the path is constructed so that
143    /// path-traversal attempts are rejected.
144    /// ## Errors
145    /// - `file_name` fails sanitization (invalid characters or extension).
146    /// - The file does not exist in the custom IR directory.
147    /// - The filesystem deletion fails.
148    pub fn remove_custom_ir_profile(&self, file_name: &str) -> Result<(), String> {
149        let sanitized_file_name = sanitize_wav_file_name(file_name)?;
150        let custom_path = self.custom_ir_directory.join(&sanitized_file_name);
151
152        if !custom_path.exists() {
153            return Err(format!(
154                "Custom IR '{}' does not exist",
155                sanitized_file_name
156            ));
157        }
158
159        self.file_loader.remove_file(&custom_path)
160    }
161
162    /// Returns the resolved absolute path of the bundled default IR directory.
163    ///
164    /// The resolution strategy differs between debug and release builds; see
165    /// [`resolve_default_ir_directory`] for details.
166    ///
167    /// [`resolve_default_ir_directory`]: Self::resolve_default_ir_directory
168    pub fn default_ir_directory(&self) -> Result<PathBuf, String> {
169        self.resolve_default_ir_directory()
170    }
171
172    /// Returns the writable custom IR directory path.
173    ///
174    /// The directory is not guaranteed to exist yet — call
175    /// [`FileLoaderTrait::ensure_directory`] before writing to it.
176    pub fn custom_ir_directory(&self) -> PathBuf {
177        self.custom_ir_directory.clone()
178    }
179
180    /// Resolves the absolute path of the bundled default IR directory.
181    /// Resolution strategy:
182    /// - **Debug builds** (`cfg(debug_assertions)`): looks inside
183    ///   `CARGO_MANIFEST_DIR/resources/default_ir` so that running
184    ///   `cargo tauri dev` works without installing the app.
185    /// - **Release builds**: checks `resource_root/default_ir` and
186    ///   `resource_root/resources/default_ir` as fallback, matching
187    ///   different Tauri resource embedding strategies.
188    ///
189    /// The first candidate path that is an existing directory is returned.
190    /// All skipped paths are logged at `WARN` level to aid debugging.
191    fn resolve_default_ir_directory(&self) -> Result<PathBuf, String> {
192        let mut candidates = if cfg!(debug_assertions) {
193            vec![PathBuf::from(env!("CARGO_MANIFEST_DIR"))
194                .join(RESOURCES_DIRECTORY_NAME)
195                .join(DEFAULT_IR_DIRECTORY_NAME)]
196        } else {
197            vec![
198                self.resource_root.join(DEFAULT_IR_DIRECTORY_NAME),
199                self.resource_root
200                    .join(RESOURCES_DIRECTORY_NAME)
201                    .join(DEFAULT_IR_DIRECTORY_NAME),
202            ]
203        };
204
205        candidates.dedup();
206
207        for candidate in &candidates {
208            if candidate.is_dir() {
209                info!("Using default IR directory: {}", candidate.display());
210                return Ok(candidate.clone());
211            }
212            warn!(
213                "Skipping missing IR directory candidate: {}",
214                candidate.display()
215            );
216        }
217
218        let searched = candidates
219            .iter()
220            .map(|p| p.display().to_string())
221            .collect::<Vec<_>>()
222            .join(", ");
223
224        Err(format!(
225            "Could not locate default IR directory. Searched: {searched}"
226        ))
227    }
228}
229
230/// Converts a `.wav` filename into a human-readable label for display in the UI.
231///
232/// Transformations applied:
233/// - file extension stripped (using `file_stem()`, handles any capitalization),
234/// - hyphens (`-`) and underscores (`_`) replaced with spaces.
235///
236/// Example: `"vintage-4x12_cab.wav"` → `"vintage 4x12 cab"`.
237fn to_readable_label(file_name: &str) -> String {
238    std::path::Path::new(file_name)
239        .file_stem()
240        .and_then(|s| s.to_str())
241        .unwrap_or(file_name)
242        .replace(['-', '_'], " ")
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248    use crate::infrastructure::file_loader::FileLoader;
249    use hound::{SampleFormat, WavSpec, WavWriter};
250    use std::fs;
251    use std::path::{Path, PathBuf};
252    use std::time::{SystemTime, UNIX_EPOCH};
253
254    fn unique_test_dir() -> PathBuf {
255        let nanos = SystemTime::now()
256            .duration_since(UNIX_EPOCH)
257            .expect("time should be monotonic")
258            .as_nanos();
259        std::env::temp_dir().join(format!("rustriff-file-service-{nanos}"))
260    }
261
262    fn build_service(custom_ir_directory: PathBuf) -> FileService {
263        FileService::new(
264            Box::new(FileLoader::new()),
265            unique_test_dir(),
266            custom_ir_directory,
267        )
268    }
269
270    fn write_float_wav_file(path: &Path, samples: &[f32]) {
271        let spec = WavSpec {
272            channels: 1,
273            sample_rate: 48_000,
274            bits_per_sample: 32,
275            sample_format: SampleFormat::Float,
276        };
277
278        let mut writer = WavWriter::create(path, spec).expect("wav file should be creatable");
279        for sample in samples {
280            writer
281                .write_sample(*sample)
282                .expect("sample should be writable");
283        }
284        writer.finalize().expect("wav writer should finalize");
285    }
286
287    fn float_wav_bytes(samples: &[f32]) -> Vec<u8> {
288        let dir = unique_test_dir();
289        fs::create_dir_all(&dir).expect("test directory should be creatable");
290        let path = dir.join("buffer.wav");
291        write_float_wav_file(&path, samples);
292        let bytes = fs::read(&path).expect("generated wav should be readable");
293        let _ = fs::remove_dir_all(dir);
294        bytes
295    }
296
297    #[cfg(test)]
298    mod success_path {
299        use super::*;
300
301        #[test]
302        fn sanitize_wav_file_name_trims_whitespace_and_accepts_valid_names() {
303            assert_eq!(
304                sanitize_wav_file_name("  my-ir.wav  ").unwrap(),
305                "my-ir.wav"
306            );
307            assert_eq!(sanitize_wav_file_name("room.WAV").unwrap(), "room.WAV");
308        }
309
310        #[test]
311        fn to_readable_label_strips_extension_and_replaces_separators() {
312            assert_eq!(
313                to_readable_label("vintage-4x12_cab.WAV"),
314                "vintage 4x12 cab"
315            );
316            assert_eq!(
317                to_readable_label("info-support-hallway.wav"),
318                "info support hallway"
319            );
320        }
321
322        #[test]
323        fn get_all_ir_profiles_merges_defaults_and_customs_and_sorts_by_label() {
324            let custom_dir = unique_test_dir();
325            fs::create_dir_all(&custom_dir).expect("custom directory should be creatable");
326            write_float_wav_file(&custom_dir.join("zzz-room.wav"), &[0.5, 0.0]);
327            write_float_wav_file(&custom_dir.join("aaa-bright.wav"), &[0.5, 0.0]);
328
329            let service = build_service(custom_dir.clone());
330            let default_names = service
331                .default_ir_directory()
332                .and_then(|dir| FileLoader::new().list_ir_profile_file_names(&dir))
333                .expect("default IRs should be discoverable");
334
335            let profiles = service
336                .get_all_ir_profiles()
337                .expect("IR profile listing should succeed");
338
339            assert_eq!(profiles.len(), default_names.len() + 2);
340            assert!(profiles
341                .windows(2)
342                .all(|pair| pair[0].label <= pair[1].label));
343            assert!(profiles.iter().all(|profile| !profile.is_in_use));
344
345            let bright = profiles
346                .iter()
347                .find(|profile| profile.file_name == "aaa-bright.wav")
348                .expect("custom bright IR should be present");
349            assert_eq!(bright.label, "aaa bright");
350            assert!(bright.is_custom);
351
352            let room = profiles
353                .iter()
354                .find(|profile| profile.file_name == "zzz-room.wav")
355                .expect("custom room IR should be present");
356            assert_eq!(room.label, "zzz room");
357            assert!(room.is_custom);
358
359            let _ = fs::remove_dir_all(custom_dir);
360        }
361
362        #[test]
363        fn save_custom_ir_profile_writes_sanitized_file_name() {
364            let custom_dir = unique_test_dir();
365            let service = build_service(custom_dir.clone());
366            let bytes = float_wav_bytes(&[0.25, 0.0, 0.0]);
367
368            let saved_name = service
369                .save_custom_ir_profile(" custom-room.wav ", &bytes)
370                .expect("valid IR should be saved");
371
372            assert_eq!(saved_name, "custom-room.wav");
373            assert!(custom_dir.join("custom-room.wav").is_file());
374
375            let _ = fs::remove_dir_all(custom_dir);
376        }
377
378        #[test]
379        fn remove_custom_ir_profile_deletes_existing_file() {
380            let custom_dir = unique_test_dir();
381            fs::create_dir_all(&custom_dir).expect("custom directory should be creatable");
382            let custom_path = custom_dir.join("to-remove.wav");
383            write_float_wav_file(&custom_path, &[0.4, 0.0]);
384
385            let service = build_service(custom_dir.clone());
386            service
387                .remove_custom_ir_profile("to-remove.wav")
388                .expect("existing custom IR should be removed");
389            assert!(!custom_path.exists());
390
391            let _ = fs::remove_dir_all(custom_dir);
392        }
393    }
394
395    #[cfg(test)]
396    mod failure_path {
397        use super::*;
398
399        #[test]
400        fn sanitize_wav_file_name_rejects_path_traversal() {
401            assert!(sanitize_wav_file_name("../escape.wav").is_err());
402            assert!(sanitize_wav_file_name("sub/dir.wav").is_err());
403            assert!(sanitize_wav_file_name("sub\\dir.wav").is_err());
404        }
405
406        #[test]
407        fn sanitize_wav_file_name_rejects_wrong_extension_and_empty_input() {
408            assert!(sanitize_wav_file_name("clip.mp3").is_err());
409            assert!(sanitize_wav_file_name("   ").is_err());
410            assert!(sanitize_wav_file_name("").is_err());
411        }
412
413        #[test]
414        fn save_custom_ir_profile_rejects_name_that_shadows_a_default_ir() {
415            let custom_dir = unique_test_dir();
416            let service = build_service(custom_dir.clone());
417            let default_name = service
418                .default_ir_directory()
419                .and_then(|dir| FileLoader::new().list_ir_profile_file_names(&dir))
420                .expect("default IRs should be discoverable")
421                .into_iter()
422                .next()
423                .expect("at least one default IR should exist");
424
425            let err = service
426                .save_custom_ir_profile(&default_name, &float_wav_bytes(&[0.5, 0.0]))
427                .expect_err("default IR names should be reserved");
428            assert!(err.contains("already exists in defaults"));
429
430            let _ = fs::remove_dir_all(custom_dir);
431        }
432
433        #[test]
434        fn save_custom_ir_profile_rejects_silent_ir() {
435            let custom_dir = unique_test_dir();
436            let service = build_service(custom_dir.clone());
437
438            let err = service
439                .save_custom_ir_profile("silent-ir.wav", &float_wav_bytes(&[0.0; 32]))
440                .expect_err("silent IR should be rejected");
441            assert!(err.contains("no impulse detected"));
442
443            let _ = fs::remove_dir_all(custom_dir);
444        }
445
446        #[test]
447        fn remove_custom_ir_profile_rejects_missing_file() {
448            let custom_dir = unique_test_dir();
449            let service = build_service(custom_dir.clone());
450
451            let err = service
452                .remove_custom_ir_profile("not-there.wav")
453                .expect_err("missing custom IR should fail removal");
454            assert!(err.contains("does not exist"));
455        }
456    }
457}