rustriff_lib/services/
file_service.rs1use 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";
9const IMPULSE_THRESHOLD: f32 = 1e-6;
13
14pub struct FileService {
25 file_loader: Box<dyn FileLoaderTrait>,
26 resource_root: PathBuf,
30 custom_ir_directory: PathBuf,
32}
33
34impl FileService {
35 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 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 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 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 pub fn default_ir_directory(&self) -> Result<PathBuf, String> {
169 self.resolve_default_ir_directory()
170 }
171
172 pub fn custom_ir_directory(&self) -> PathBuf {
177 self.custom_ir_directory.clone()
178 }
179
180 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
230fn 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}