Skip to main content

rustriff_lib/commands/effect_commands/
cabinet_ir.rs

1use crate::domain::channel::Channel;
2use crate::domain::dto::effect::ir_profile_dto::IrProfileDto;
3use crate::services::audio_service::AudioService;
4use crate::services::file_service::FileService;
5use std::collections::HashSet;
6use std::sync::Mutex;
7use tracing::{info, warn};
8
9/// Returns the full list of available IR profiles annotated with usage state.
10///
11/// This command merges:
12/// - **default** profiles from the bundled `resources/default_ir` directory,
13/// - **custom** profiles from the user's writable custom IR directory.
14///
15/// Each [`IrProfileDto::is_in_use`] flag reflects whether any Cabinet effect
16/// in any active channel currently references that profile.  The frontend uses
17/// this to disable the remove-button for profiles that are in active use.
18///
19/// ## Errors
20/// Propagates errors from [`FileService::get_all_ir_profiles`] or locking the
21/// audio service.
22#[tauri::command]
23pub fn get_all_ir_profiles(
24    file_service: tauri::State<FileService>,
25    audio_service: tauri::State<Mutex<AudioService>>,
26) -> Result<Vec<IrProfileDto>, String> {
27    let used_profiles = used_ir_profiles(&audio_service).map_err(|err| {
28        warn!("get_all_ir_profiles failed while reading used profiles: {err}");
29        err
30    })?;
31    let mut profiles = file_service.get_all_ir_profiles().map_err(|err| {
32        warn!("get_all_ir_profiles failed while reading profile inventory: {err}");
33        err
34    })?;
35
36    mark_profiles_in_use(&mut profiles, &used_profiles);
37
38    Ok(profiles)
39}
40
41/// Uploads a custom IR WAV file to the user's custom IR directory.
42///
43/// The upload pipeline:
44/// 1. The frontend reads the user-selected file into a `Uint8Array` and sends
45///    the raw bytes together with the original filename.
46/// 2. This command delegates to [`FileService::save_custom_ir_profile`], which
47///    sanitizes the name, validates the WAV data, and writes the file to disk.
48/// 3. On success, the sanitized filename is returned so the frontend can
49///    immediately add the new entry to the IR list without re-fetching.
50///
51/// ## Errors
52///
53/// Returns `Err` when the file fails validation (wrong extension, no impulse,
54/// duplicate of a default profile) or cannot be written to disk.
55#[tauri::command]
56pub fn upload_ir_profile(
57    file_service: tauri::State<FileService>,
58    file_name: String,
59    file_bytes: Vec<u8>,
60) -> Result<String, String> {
61    info!(
62        "Uploading custom IR profile '{}' ({} bytes)",
63        file_name,
64        file_bytes.len()
65    );
66    file_service
67        .save_custom_ir_profile(&file_name, &file_bytes)
68        .map_err(|err| {
69            warn!("upload_ir_profile failed for '{}': {err}", file_name);
70            err
71        })
72}
73
74/// Removes a custom IR profile from the user's custom IR directory.
75/// The following safety guards are enforced before deletion:
76/// 1. **Must exist** – the profile must appear in the profile inventory.
77/// 2. **Must be custom** – default (bundled) profiles cannot be removed.
78/// 3. **Must not be in use** – if any Cabinet effect in any active channel
79///    currently references this profile, deletion is rejected to avoid
80///    corrupting the live signal chain.
81///
82/// ## Errors
83/// Returns `Err` with a user-facing message when any guard fails.
84#[tauri::command]
85pub fn remove_ir_profile(
86    file_service: tauri::State<FileService>,
87    audio_service: tauri::State<Mutex<AudioService>>,
88    file_name: String,
89) -> Result<(), String> {
90    let profiles = file_service.get_all_ir_profiles().map_err(|err| {
91        warn!("remove_ir_profile failed while reading profile inventory: {err}");
92        err
93    })?;
94
95    let used_profiles = used_ir_profiles(&audio_service).map_err(|err| {
96        warn!("remove_ir_profile failed while checking chain usage: {err}");
97        err
98    })?;
99    ensure_profile_can_be_removed(&profiles, &file_name, &used_profiles)?;
100
101    file_service
102        .remove_custom_ir_profile(&file_name)
103        .map_err(|err| {
104            warn!("remove_ir_profile failed for '{}': {err}", file_name);
105            err
106        })
107}
108
109fn mark_profiles_in_use(profiles: &mut [IrProfileDto], used_profiles: &HashSet<String>) {
110    for profile in profiles {
111        profile.is_in_use = used_profiles.contains(&profile.file_name);
112    }
113}
114
115fn ensure_profile_can_be_removed(
116    profiles: &[IrProfileDto],
117    file_name: &str,
118    used_profiles: &HashSet<String>,
119) -> Result<(), String> {
120    let profile = profiles
121        .iter()
122        .find(|p| p.file_name == file_name)
123        .ok_or_else(|| format!("IR profile '{}' not found", file_name))?;
124
125    if !profile.is_custom {
126        return Err("Default IR profiles cannot be removed".to_string());
127    }
128
129    if used_profiles.contains(file_name) {
130        return Err(format!(
131            "IR profile '{}' is currently used by an effect chain",
132            file_name
133        ));
134    }
135
136    Ok(())
137}
138
139/// Collects the `ir_file_path` values of every Cabinet effect across all
140/// active channels into a deduplicated `HashSet`.
141///
142/// Used by [`get_all_ir_profiles`] and [`remove_ir_profile`] to determine
143/// which IR profiles are currently referenced by the live effect chains.
144fn used_ir_profiles(
145    audio_service: &tauri::State<Mutex<AudioService>>,
146) -> Result<HashSet<String>, String> {
147    let service = audio_service
148        .lock()
149        .map_err(|_| "Failed to lock audio service".to_string())?;
150    collect_used_ir_profiles(service.channels())
151}
152
153fn collect_used_ir_profiles(channels: &[Channel]) -> Result<HashSet<String>, String> {
154    let mut used = HashSet::new();
155    for channel in channels.iter() {
156        used.extend(channel.used_cabinet_ir_profiles());
157    }
158
159    Ok(used)
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use crate::services::audio_service::AudioService;
166    use crate::services::effects::cabinet::cabinet::Cabinet;
167    use crate::tests::mock::make_mock_handler;
168    use std::sync::Arc;
169
170    fn profile(file_name: &str, is_custom: bool) -> IrProfileDto {
171        IrProfileDto {
172            file_name: file_name.to_string(),
173            label: file_name.to_string(),
174            is_custom,
175            is_in_use: false,
176        }
177    }
178
179    #[cfg(test)]
180    mod success_path {
181        use super::*;
182
183        #[test]
184        fn collect_used_ir_profiles_deduplicates_cabinet_profiles_across_channels() {
185            let mut service = AudioService::new_with_handler(Arc::new(make_mock_handler()));
186
187            service.channels_mut()[0].add_effect_to_chain(Box::new(Cabinet::new(
188                0,
189                "Cab A".to_string(),
190                true,
191                "#111111".to_string(),
192                "Reverb-oxford-lean.wav".to_string(),
193                48_000,
194            )));
195
196            let second_channel_id = service.add_channel("Lead".to_string());
197            let second_channel = service
198                .channels_mut()
199                .iter_mut()
200                .find(|channel| channel.id() == second_channel_id)
201                .expect("second channel should exist");
202            second_channel.add_effect_to_chain(Box::new(Cabinet::new(
203                0,
204                "Cab B".to_string(),
205                true,
206                "#222222".to_string(),
207                "Reverb-oxford-lean.wav".to_string(),
208                48_000,
209            )));
210            second_channel.add_effect_to_chain(Box::new(Cabinet::new(
211                1,
212                "Cab C".to_string(),
213                true,
214                "#333333".to_string(),
215                "Vox-ac30.wav".to_string(),
216                48_000,
217            )));
218
219            let used = collect_used_ir_profiles(service.channels())
220                .expect("used IR profile discovery should succeed");
221
222            assert_eq!(used.len(), 2);
223            assert!(used.contains("Reverb-oxford-lean.wav"));
224            assert!(used.contains("Vox-ac30.wav"));
225        }
226
227        #[test]
228        fn mark_profiles_in_use_sets_flags_from_used_set() {
229            let mut profiles = vec![
230                profile("Vox-ac30.wav", false),
231                profile("custom-room.wav", true),
232            ];
233            let used_profiles = HashSet::from(["custom-room.wav".to_string()]);
234
235            mark_profiles_in_use(&mut profiles, &used_profiles);
236
237            assert!(!profiles[0].is_in_use);
238            assert!(profiles[1].is_in_use);
239        }
240
241        #[test]
242        fn ensure_profile_can_be_removed_accepts_unused_custom_profile() {
243            let profiles = vec![profile("custom-room.wav", true)];
244
245            ensure_profile_can_be_removed(&profiles, "custom-room.wav", &HashSet::new())
246                .expect("unused custom profile should be removable");
247        }
248
249        #[test]
250        fn collect_used_ir_profiles_reflects_restored_chain_metadata() {
251            let mut service = AudioService::new_with_handler(Arc::new(make_mock_handler()));
252
253            service.channels_mut()[0].add_effect_to_chain(Box::new(Cabinet::new(
254                0,
255                "Cab A".to_string(),
256                true,
257                "#111111".to_string(),
258                "Vox-ac30.wav".to_string(),
259                48_000,
260            )));
261
262            service.channels_mut()[0].restore_effect_chain(vec![Box::new(Cabinet::new(
263                7,
264                "Cab B".to_string(),
265                true,
266                "#222222".to_string(),
267                "Reverb-oxford-lean.wav".to_string(),
268                48_000,
269            ))]);
270
271            let used = collect_used_ir_profiles(service.channels())
272                .expect("restored chain usage discovery should succeed");
273
274            assert_eq!(used.len(), 1);
275            assert!(used.contains("Reverb-oxford-lean.wav"));
276            assert!(!used.contains("Vox-ac30.wav"));
277        }
278    }
279
280    #[cfg(test)]
281    mod failure_path {
282        use super::*;
283
284        #[test]
285        fn ensure_profile_can_be_removed_rejects_missing_profile() {
286            let profiles = vec![profile("Vox-ac30.wav", false)];
287
288            let err = ensure_profile_can_be_removed(&profiles, "missing.wav", &HashSet::new())
289                .expect_err("missing profile should fail");
290            assert!(err.contains("not found"));
291        }
292
293        #[test]
294        fn ensure_profile_can_be_removed_rejects_default_profile() {
295            let profiles = vec![profile("Vox-ac30.wav", false)];
296
297            assert_eq!(
298                ensure_profile_can_be_removed(&profiles, "Vox-ac30.wav", &HashSet::new())
299                    .expect_err("default profile should be protected"),
300                "Default IR profiles cannot be removed"
301            );
302        }
303
304        #[test]
305        fn ensure_profile_can_be_removed_rejects_in_use_custom_profile() {
306            let profiles = vec![profile("custom-room.wav", true)];
307            let used_profiles = HashSet::from(["custom-room.wav".to_string()]);
308
309            let err = ensure_profile_can_be_removed(&profiles, "custom-room.wav", &used_profiles)
310                .expect_err("in-use profile should be protected");
311            assert!(err.contains("currently used by an effect chain"));
312        }
313    }
314}