Skip to main content

ot_tools_io/projects/slots/
parsing.rs

1/*
2SPDX-License-Identifier: GPL-3.0-or-later
3Copyright © 2026 Mike Robeson [dijksterhuis]
4*/
5
6use crate::errors::{InvalidValueError, ProjectParseError};
7use crate::generics::Slots;
8use crate::projects::{parse_hashmap_string_value, SlotAttributes, DEFAULT_GAIN, DEFAULT_TEMPO};
9use crate::settings::{LoopMode, SlotType, TimeStretchMode, TrigQuantizationMode};
10use itertools::Itertools;
11use std::collections::HashMap;
12use std::fmt;
13use std::path::PathBuf;
14use std::str::FromStr;
15
16pub fn parse_id(hmap: &HashMap<String, String>) -> Result<u8, ProjectParseError> {
17    let x = parse_hashmap_string_value::<u8>(hmap, "slot", None)?;
18    Ok(x)
19}
20
21pub fn parse_loop_mode(hmap: &HashMap<String, String>) -> Result<LoopMode, InvalidValueError> {
22    let default = LoopMode::default() as u8;
23    let default_str = format!["{default}"];
24
25    let x = parse_hashmap_string_value::<u8>(hmap, "loopmode", Some(default_str.as_str()))
26        .unwrap_or(default);
27    LoopMode::try_from(&x)
28}
29
30pub fn parse_tstrech_mode(
31    hmap: &HashMap<String, String>,
32) -> Result<TimeStretchMode, InvalidValueError> {
33    let default = TimeStretchMode::default() as u8;
34    let default_str = format!["{default}"];
35
36    let x = parse_hashmap_string_value::<u8>(hmap, "tsmode", Some(default_str.as_str()))
37        .unwrap_or(default);
38    TimeStretchMode::try_from(&x)
39}
40
41pub fn parse_trig_quantize_mode(
42    hmap: &HashMap<String, String>,
43) -> Result<TrigQuantizationMode, InvalidValueError> {
44    let default = TrigQuantizationMode::default() as u8;
45    let default_str = format!["{default}"];
46    let x = parse_hashmap_string_value::<u8>(hmap, "trigquantization", Some(default_str.as_str()))
47        .unwrap_or(default);
48    TrigQuantizationMode::try_from(x)
49}
50
51pub fn parse_gain(hmap: &HashMap<String, String>) -> Result<u8, ProjectParseError> {
52    // note: default slot gain is 48, except for recording buffers which are 72.
53    // see:
54    // - `flex_slot_default_case_switch`
55    // - `test-data/blank-project/project.work`
56    let x =
57        parse_hashmap_string_value::<u8>(hmap, "gain", Some(format!["{DEFAULT_GAIN}"].as_str()))
58            .unwrap_or(DEFAULT_GAIN);
59    Ok(x)
60}
61
62pub fn parse_tempo(hmap: &HashMap<String, String>) -> Result<u16, ProjectParseError> {
63    let x = parse_hashmap_string_value::<u16>(
64        hmap,
65        "bpmx24",
66        Some(format!["{DEFAULT_TEMPO}"].as_str()),
67    )
68    .unwrap_or(DEFAULT_TEMPO);
69    Ok(x)
70}
71
72pub fn parse_path(hmap: &HashMap<String, String>) -> Result<PathBuf, ProjectParseError> {
73    let path_str = hmap.get("path").ok_or(ProjectParseError::HashMap)?;
74    let path = PathBuf::from_str(path_str).map_err(|_| ProjectParseError::String)?;
75    Ok(path)
76}
77
78impl TryFrom<&HashMap<String, String>> for SlotAttributes {
79    type Error = ProjectParseError;
80    fn try_from(value: &HashMap<String, String>) -> Result<Self, Self::Error> {
81        let slot_id = parse_id(value)?;
82
83        let sample_slot_type = value
84            .get("type")
85            .ok_or(ProjectParseError::HashMap)?
86            .to_string();
87        let slot_type = SlotType::try_from(sample_slot_type)?;
88
89        let path = parse_path(value)?;
90
91        let loop_mode = parse_loop_mode(value)?;
92        let timestrech_mode = parse_tstrech_mode(value)?;
93        let trig_quantization_mode = parse_trig_quantize_mode(value)?;
94        let gain = parse_gain(value)?;
95        let bpm = parse_tempo(value)?;
96
97        let sample_struct = Self {
98            slot_type,
99            slot_id,
100            path: if path.as_os_str() != "" {
101                Some(path)
102            } else {
103                None
104            },
105            timestrech_mode,
106            loop_mode,
107            trig_quantization_mode,
108            gain,
109            bpm,
110        };
111
112        Ok(sample_struct)
113    }
114}
115
116impl FromStr for SlotAttributes {
117    type Err = ProjectParseError;
118
119    fn from_str(s: &str) -> Result<Self, Self::Err> {
120        let k_v: Vec<Vec<&str>> = s
121            .strip_prefix("\r\n\r\n[SAMPLE]\r\n")
122            .ok_or(ProjectParseError::HashMap)?
123            .strip_suffix("\r\n")
124            .ok_or(ProjectParseError::HashMap)?
125            .split("\r\n")
126            .map(|x: &str| x.split('=').collect_vec())
127            .filter(|x: &Vec<&str>| x.len() == 2)
128            .collect_vec();
129
130        let mut hmap: HashMap<String, String> = HashMap::new();
131        for key_value_pair in k_v {
132            hmap.insert(
133                key_value_pair[0].to_string().to_lowercase(),
134                key_value_pair[1].to_string(),
135            );
136        }
137
138        let sample_struct = SlotAttributes::try_from(&hmap)?;
139        Ok(sample_struct)
140    }
141}
142
143impl fmt::Display for SlotAttributes {
144    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
145        let mut s = "[SAMPLE]\r\n".to_string();
146        s.push_str(&format!("TYPE={}", self.slot_type));
147        s.push_str("\r\n");
148        // NOTE: Slot ID data is always prefixed with leading zeros
149        s.push_str(format!("SLOT={:0>3}", self.slot_id).as_str());
150        s.push_str("\r\n");
151        // NOTE: Handle recording buffers having empty paths
152        if let Some(path) = &self.path {
153            /*
154            HACK: Remove escape characters from the path string.
155
156            A path like `my\file.wav` ends up like `my\\\\[...loads more \ chars...]\\\file.wav`.
157            The Octatrack will attempt to load the project and then a catastrophic error message pops up.
158            Helpfully, it will have made changes to the `project.work` file already which means you
159            have to manually fix it yourself.
160
161            This looks like some recursive injection of escape characters during parsing of the PATH field.
162            Basically ... don't use the `\` escape character in path names!
163            */
164            s.push_str(
165                format!("PATH={path:#?}")
166                    .replace('"', "") // should not have quotes on PATH fields
167                    .replace("\\", "") // ^ need to remove escape chars (`\`)
168                    .as_str(),
169            );
170        } else {
171            s.push_str("PATH=");
172        }
173        s.push_str("\r\n");
174        s.push_str(format!("BPMx24={}", self.bpm).as_str());
175        s.push_str("\r\n");
176        s.push_str(format!("TSMODE={}", self.timestrech_mode as u8).as_str());
177        s.push_str("\r\n");
178        s.push_str(format!("LOOPMODE={}", self.loop_mode as u8).as_str());
179        s.push_str("\r\n");
180        s.push_str(format!("GAIN={}", self.gain).as_str());
181        s.push_str("\r\n");
182        s.push_str(format!("TRIGQUANTIZATION={}", self.trig_quantization_mode as u8).as_str());
183        s.push_str("\r\n[/SAMPLE]");
184        write!(f, "{s:#}")
185    }
186}
187
188impl fmt::Display for Slots<Option<SlotAttributes>> {
189    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
190        let mut string_slots: String = "".to_string();
191
192        let slots = vec![
193            self.static_slots.to_vec(),
194            self.flex_slots.to_vec(),
195            self.recording_buffers.to_vec(),
196        ];
197
198        let slots_concat = itertools::concat(slots).into_iter().flatten();
199
200        for slot in slots_concat {
201            string_slots.push_str(&slot.to_string());
202            string_slots.push_str("\r\n\r\n");
203        }
204        string_slots = string_slots
205            .strip_suffix("\r\n\r\n")
206            .ok_or(fmt::Error)?
207            .to_string();
208        write!(f, "{string_slots:#}")
209    }
210}
211
212#[cfg(test)]
213mod test_slot_attr_display {
214    use crate::projects::slots::test_utils;
215    use crate::OtToolsIoError;
216    #[test]
217    fn valid_full() -> Result<(), OtToolsIoError> {
218        test_utils::new_slot_attr_full_args()?.to_string();
219        Ok(())
220    }
221
222    #[test]
223    fn valid_minimal() -> Result<(), OtToolsIoError> {
224        test_utils::new_slot_attr_minimal_args()?.to_string();
225        Ok(())
226    }
227}
228
229fn collect_slots_by_id_and_type(
230    slots: &mut Slots<Option<SlotAttributes>>,
231    string: &str,
232) -> Result<(), ProjectParseError> {
233    // need zero indexing to insert slots into arrays
234    let slot = SlotAttributes::from_str(string)?;
235    let zero_indexed_id = slot.slot_id as usize - 1;
236    match slot.slot_type {
237        SlotType::Static if slot.slot_id <= 128 => {
238            slots.static_slots[zero_indexed_id] = Some(slot);
239            Ok(())
240        }
241        SlotType::Flex if slot.slot_id <= 128 => {
242            slots.flex_slots[zero_indexed_id] = Some(slot);
243            Ok(())
244        }
245        SlotType::Flex if slot.slot_id > 128 && slot.slot_id <= 136 => {
246            let index = slot.slot_id as usize - 129;
247            slots.recording_buffers[index] = Some(slot);
248            Ok(())
249        }
250        _ => Err(ProjectParseError::String),
251    }
252}
253
254impl FromStr for Slots<Option<SlotAttributes>> {
255    type Err = ProjectParseError;
256
257    fn from_str(s: &str) -> Result<Self, Self::Err> {
258        let footer_stripped = s
259            .strip_suffix("\r\n\r\n############################\r\n\r\n")
260            .ok_or(ProjectParseError::Footer)?;
261
262        let data_window: Vec<&str> = footer_stripped
263            .split("############################\r\n# Samples\r\n############################")
264            .collect();
265
266        let mut samples_string: Vec<&str> = data_window[1].split("[/SAMPLE]").collect();
267        // last one is always a blank string.
268        samples_string.pop();
269
270        // mutate from default as we always need recording buffers populated
271        let mut slots = Self::default();
272
273        for s in &samples_string {
274            collect_slots_by_id_and_type(&mut slots, s)?;
275        }
276
277        Ok(slots)
278    }
279}
280
281#[cfg(test)]
282#[allow(unused_imports)]
283mod test {
284
285    #[test]
286    fn parse_id_001_correct() {
287        let mut hmap = std::collections::HashMap::new();
288        hmap.insert("slot".to_string(), "001".to_string());
289
290        let slot_id = crate::projects::slots::parsing::parse_id(&hmap);
291
292        assert_eq!(1, slot_id.unwrap());
293    }
294
295    #[test]
296    fn parse_id_1_correct() {
297        let mut hmap = std::collections::HashMap::new();
298        hmap.insert("slot".to_string(), "1".to_string());
299
300        let slot_id = crate::projects::slots::parsing::parse_id(&hmap);
301
302        assert_eq!(1, slot_id.unwrap());
303    }
304
305    #[test]
306    fn parse_id_127_correct() {
307        let mut hmap = std::collections::HashMap::new();
308        hmap.insert("slot".to_string(), "127".to_string());
309
310        let slot_id = crate::projects::slots::parsing::parse_id(&hmap);
311
312        assert_eq!(127, slot_id.unwrap());
313    }
314
315    #[test]
316    fn parse_id_099_correct() {
317        let mut hmap = std::collections::HashMap::new();
318        hmap.insert("slot".to_string(), "099".to_string());
319
320        let slot_id = crate::projects::slots::parsing::parse_id(&hmap);
321
322        assert_eq!(99, slot_id.unwrap());
323    }
324
325    #[test]
326    fn parse_id_010_correct() {
327        let mut hmap = std::collections::HashMap::new();
328        hmap.insert("slot".to_string(), "010".to_string());
329
330        let slot_id = crate::projects::slots::parsing::parse_id(&hmap);
331
332        assert_eq!(10, slot_id.unwrap());
333    }
334
335    #[test]
336    fn test_parse_id_err_bad_value_type_err() {
337        let mut hmap = std::collections::HashMap::new();
338        hmap.insert("slot".to_string(), "AAAA".to_string());
339        let slot_id = crate::projects::slots::parsing::parse_id(&hmap);
340        assert!(slot_id.is_err());
341    }
342
343    #[test]
344    fn test_parse_tempo_correct_default() {
345        let mut hmap = std::collections::HashMap::new();
346        hmap.insert("bpmx24".to_string(), "2880".to_string());
347        let r = crate::projects::slots::parsing::parse_tempo(&hmap);
348        assert_eq!(2880_u16, r.unwrap());
349    }
350
351    #[test]
352    fn test_parse_tempo_correct_min() {
353        let mut hmap = std::collections::HashMap::new();
354        hmap.insert("bpmx24".to_string(), "720".to_string());
355        let r = crate::projects::slots::parsing::parse_tempo(&hmap);
356        assert_eq!(720_u16, r.unwrap());
357    }
358
359    #[test]
360    fn test_parse_tempo_correct_max() {
361        let mut hmap = std::collections::HashMap::new();
362        hmap.insert("bpmx24".to_string(), "7200".to_string());
363        let r = crate::projects::slots::parsing::parse_tempo(&hmap);
364        assert_eq!(7200_u16, r.unwrap());
365    }
366
367    #[test]
368    fn test_parse_tempo_bad_value_type_default_return() {
369        let mut hmap = std::collections::HashMap::new();
370        hmap.insert("bpmx24".to_string(), "AAAFSFSFSSFfssafAA".to_string());
371        let r = crate::projects::slots::parsing::parse_tempo(&hmap);
372        assert_eq!(r.unwrap(), 2880_u16);
373    }
374
375    #[test]
376    fn test_parse_gain_correct() {
377        let mut hmap = std::collections::HashMap::new();
378        hmap.insert("gain".to_string(), "72".to_string());
379        let r = crate::projects::slots::parsing::parse_gain(&hmap);
380        assert_eq!(72, r.unwrap());
381    }
382
383    #[test]
384    fn test_parse_gain_bad_value_type_default_return() {
385        let mut hmap = std::collections::HashMap::new();
386        hmap.insert("gain".to_string(), "AAAFSFSFSSFfssafAA".to_string());
387        let r = crate::projects::slots::parsing::parse_gain(&hmap);
388        assert_eq!(r.unwrap(), super::DEFAULT_GAIN); // note: recording slots have a different default value
389    }
390
391    #[test]
392    fn test_parse_loop_mode_correct_off() {
393        let mut hmap = std::collections::HashMap::new();
394        hmap.insert("loopmode".to_string(), "0".to_string());
395        let r = crate::projects::slots::parsing::parse_loop_mode(&hmap);
396        assert_eq!(r.unwrap(), crate::settings::LoopMode::Off);
397    }
398
399    #[test]
400    fn test_parse_loop_mode_correct_normal() {
401        let mut hmap = std::collections::HashMap::new();
402        hmap.insert("loopmode".to_string(), "1".to_string());
403        let r = crate::projects::slots::parsing::parse_loop_mode(&hmap);
404        assert_eq!(r.unwrap(), crate::settings::LoopMode::Normal);
405    }
406
407    #[test]
408    fn test_parse_loop_mode_correct_pingpong() {
409        let mut hmap = std::collections::HashMap::new();
410        hmap.insert("loopmode".to_string(), "2".to_string());
411        let r = crate::projects::slots::parsing::parse_loop_mode(&hmap);
412        assert_eq!(r.unwrap(), crate::settings::LoopMode::PingPong);
413    }
414
415    #[test]
416    fn test_parse_loop_mode_bad_value_type_default_return() {
417        let mut hmap = std::collections::HashMap::new();
418        hmap.insert("loopmode".to_string(), "AAAFSFSFSSFfssafAA".to_string());
419        let r = crate::projects::slots::parsing::parse_loop_mode(&hmap);
420        assert_eq!(r.unwrap(), crate::settings::LoopMode::default());
421    }
422
423    #[test]
424    fn test_parse_tstretch_correct_off() {
425        let mut hmap = std::collections::HashMap::new();
426        hmap.insert("tsmode".to_string(), "0".to_string());
427        let r = crate::projects::slots::parsing::parse_tstrech_mode(&hmap);
428        assert_eq!(crate::settings::TimeStretchMode::Off, r.unwrap());
429    }
430
431    #[test]
432    fn test_parse_tstretch_correct_normal() {
433        let mut hmap = std::collections::HashMap::new();
434        hmap.insert("tsmode".to_string(), "2".to_string());
435        let r = crate::projects::slots::parsing::parse_tstrech_mode(&hmap);
436        assert_eq!(crate::settings::TimeStretchMode::Normal, r.unwrap());
437    }
438
439    #[test]
440    fn test_parse_tstretch_correct_beat() {
441        let mut hmap = std::collections::HashMap::new();
442        hmap.insert("tsmode".to_string(), "3".to_string());
443        let r = crate::projects::slots::parsing::parse_tstrech_mode(&hmap);
444        assert_eq!(crate::settings::TimeStretchMode::Beat, r.unwrap());
445    }
446
447    #[test]
448    fn test_parse_tstretch_bad_value_type_default_return() {
449        let mut hmap = std::collections::HashMap::new();
450        hmap.insert("tsmode".to_string(), "AAAFSFSFSSFfssafAA".to_string());
451        let r = crate::projects::slots::parsing::parse_tstrech_mode(&hmap);
452        assert_eq!(r.unwrap(), crate::settings::TimeStretchMode::default());
453    }
454
455    #[test]
456    fn test_parse_tquantize_correct_off() {
457        let mut hmap = std::collections::HashMap::new();
458        hmap.insert("trigquantization".to_string(), "255".to_string());
459        let r = crate::projects::slots::parsing::parse_trig_quantize_mode(&hmap);
460        assert_eq!(crate::settings::TrigQuantizationMode::Direct, r.unwrap());
461    }
462
463    #[test]
464    fn test_parse_tquantize_correct_direct() {
465        let mut hmap = std::collections::HashMap::new();
466        hmap.insert("trigquantization".to_string(), "0".to_string());
467        let r = crate::projects::slots::parsing::parse_trig_quantize_mode(&hmap);
468        assert_eq!(
469            crate::settings::TrigQuantizationMode::PatternLength,
470            r.unwrap()
471        );
472    }
473
474    #[test]
475    fn test_parse_tquantize_correct_onestep() {
476        let mut hmap = std::collections::HashMap::new();
477        hmap.insert("trigquantization".to_string(), "1".to_string());
478        let r = crate::projects::slots::parsing::parse_trig_quantize_mode(&hmap);
479        assert_eq!(crate::settings::TrigQuantizationMode::OneStep, r.unwrap());
480    }
481
482    #[test]
483    fn test_parse_tquantize_correct_twostep() {
484        let mut hmap = std::collections::HashMap::new();
485        hmap.insert("trigquantization".to_string(), "2".to_string());
486        let r = crate::projects::slots::parsing::parse_trig_quantize_mode(&hmap);
487        assert_eq!(crate::settings::TrigQuantizationMode::TwoSteps, r.unwrap());
488    }
489
490    #[test]
491    fn test_parse_tquantize_correct_threestep() {
492        let mut hmap = std::collections::HashMap::new();
493        hmap.insert("trigquantization".to_string(), "3".to_string());
494        let r = crate::projects::slots::parsing::parse_trig_quantize_mode(&hmap);
495        assert_eq!(
496            crate::settings::TrigQuantizationMode::ThreeSteps,
497            r.unwrap()
498        );
499    }
500
501    #[test]
502    fn test_parse_tquantize_correct_fourstep() {
503        let mut hmap = std::collections::HashMap::new();
504        hmap.insert("trigquantization".to_string(), "4".to_string());
505        let r = crate::projects::slots::parsing::parse_trig_quantize_mode(&hmap);
506        assert_eq!(crate::settings::TrigQuantizationMode::FourSteps, r.unwrap());
507    }
508
509    // i'm not going to test every single option. we do that already elsewhere.
510
511    #[test]
512    fn test_parse_tquantize_bad_value_type_default_return() {
513        let mut hmap = std::collections::HashMap::new();
514        hmap.insert(
515            "trigquantization".to_string(),
516            "AAAFSFSFSSFfssafAA".to_string(),
517        );
518        let r = crate::projects::slots::parsing::parse_trig_quantize_mode(&hmap);
519        assert_eq!(r.unwrap(), crate::settings::TrigQuantizationMode::default());
520    }
521
522    use std::path::PathBuf;
523    #[test]
524    fn test_parse_path_good_utf8_value() {
525        let test_path = "../AUDIO/some/file.wav";
526
527        let mut hmap = std::collections::HashMap::new();
528        hmap.insert("path".to_string(), test_path.to_string());
529        let r = crate::projects::slots::parsing::parse_path(&hmap);
530        assert_eq!(r.unwrap(), PathBuf::from(test_path));
531    }
532
533    #[test]
534    fn test_parse_empty_path() {
535        let test_path = "";
536
537        let mut hmap = std::collections::HashMap::new();
538        hmap.insert("path".to_string(), test_path.to_string());
539        let r = crate::projects::slots::parsing::parse_path(&hmap);
540        assert_eq!(r.unwrap(), PathBuf::from(test_path));
541    }
542
543    #[test]
544    fn test_parse_non_utf8_path() {
545        let test_path = "../AUDIO/🇯🇲something.wav";
546
547        let mut hmap = std::collections::HashMap::new();
548        hmap.insert("path".to_string(), test_path.to_string());
549        let r = crate::projects::slots::parsing::parse_path(&hmap);
550        assert_eq!(r.unwrap(), PathBuf::from(test_path));
551    }
552}