Skip to main content

max / audiofiles

7.5 KB · 281 lines History Blame Raw
1 //! TOML manifest parsing and conversion to `DeviceProfile`.
2
3 use serde::Deserialize;
4
5 use tracing::instrument;
6
7 use audiofiles_core::export::profile::{
8 AudioConstraints, ChannelConstraint, DeviceProfile, ExportConstraints, NamingCase, NamingRules,
9 };
10 use audiofiles_core::export::ExportFormat;
11
12 use crate::error::PluginError;
13
14 /// Raw TOML manifest structure.
15 #[derive(Debug, Deserialize)]
16 pub struct PluginManifest {
17 pub device: DeviceSection,
18 pub audio: AudioSection,
19 pub naming: Option<NamingSection>,
20 pub limits: Option<LimitsSection>,
21 pub hooks: Option<HooksSection>,
22 }
23
24 #[derive(Debug, Deserialize)]
25 pub struct DeviceSection {
26 pub name: String,
27 pub manufacturer: String,
28 pub version: String,
29 #[serde(default)]
30 pub category: Option<String>,
31 #[serde(default)]
32 pub notes: Option<String>,
33 }
34
35 #[derive(Debug, Deserialize)]
36 pub struct AudioSection {
37 pub formats: Vec<String>,
38 pub sample_rates: Vec<u32>,
39 pub bit_depths: Vec<u16>,
40 pub channels: String,
41 }
42
43 #[derive(Debug, Deserialize)]
44 pub struct NamingSection {
45 pub case: String,
46 pub separator: String,
47 pub max_length: usize,
48 pub strip_special: bool,
49 }
50
51 #[derive(Debug, Deserialize)]
52 pub struct LimitsSection {
53 pub max_file_size_bytes: Option<u64>,
54 pub max_sample_count: Option<usize>,
55 }
56
57 #[derive(Debug, Deserialize)]
58 pub struct HooksSection {
59 pub validate_sample: Option<String>,
60 pub transform_filename: Option<String>,
61 pub pre_export: Option<String>,
62 pub post_export: Option<String>,
63 }
64
65 /// Parse a TOML string into a `PluginManifest`.
66 #[instrument(skip_all)]
67 pub fn parse_manifest(toml_str: &str) -> Result<PluginManifest, PluginError> {
68 toml::from_str(toml_str).map_err(|e| PluginError::ManifestParse(e.to_string()))
69 }
70
71 /// Convert a parsed manifest into a `DeviceProfile`.
72 #[instrument(skip_all)]
73 pub fn manifest_to_profile(manifest: &PluginManifest) -> Result<DeviceProfile, PluginError> {
74 let formats = manifest
75 .audio
76 .formats
77 .iter()
78 .map(|f| parse_format(f))
79 .collect::<Result<Vec<_>, _>>()?;
80
81 let channels = parse_channels(&manifest.audio.channels)?;
82
83 let naming = manifest
84 .naming
85 .as_ref()
86 .map(parse_naming)
87 .transpose()?;
88
89 let limits = manifest.limits.as_ref().map(|l| ExportConstraints {
90 max_file_size_bytes: l.max_file_size_bytes,
91 max_sample_count: l.max_sample_count,
92 });
93
94 Ok(DeviceProfile {
95 name: manifest.device.name.clone(),
96 manufacturer: manifest.device.manufacturer.clone(),
97 category: manifest.device.category.clone(),
98 notes: manifest.device.notes.clone(),
99 audio: AudioConstraints {
100 formats,
101 sample_rates: manifest.audio.sample_rates.clone(),
102 bit_depths: manifest.audio.bit_depths.clone(),
103 channels,
104 },
105 naming,
106 limits,
107 })
108 }
109
110 fn parse_format(s: &str) -> Result<ExportFormat, PluginError> {
111 match s.to_lowercase().as_str() {
112 "wav" => Ok(ExportFormat::Wav),
113 "aiff" | "aif" => Ok(ExportFormat::Aiff),
114 other => Err(PluginError::ManifestInvalid(format!(
115 "unknown format: {other}"
116 ))),
117 }
118 }
119
120 fn parse_channels(s: &str) -> Result<ChannelConstraint, PluginError> {
121 match s.to_lowercase().as_str() {
122 "mono" => Ok(ChannelConstraint::Mono),
123 "stereo" => Ok(ChannelConstraint::Stereo),
124 "both" => Ok(ChannelConstraint::Both),
125 other => Err(PluginError::ManifestInvalid(format!(
126 "unknown channel constraint: {other}"
127 ))),
128 }
129 }
130
131 fn parse_naming(section: &NamingSection) -> Result<NamingRules, PluginError> {
132 let case = match section.case.to_lowercase().as_str() {
133 "lower" => NamingCase::Lower,
134 "upper" => NamingCase::Upper,
135 "original" => NamingCase::Original,
136 other => {
137 return Err(PluginError::ManifestInvalid(format!(
138 "unknown naming case: {other}"
139 )));
140 }
141 };
142
143 if section.separator.chars().count() != 1 {
144 return Err(PluginError::ManifestInvalid(format!(
145 "separator must be exactly one character, got {:?}",
146 section.separator,
147 )));
148 }
149 let separator = section
150 .separator
151 .chars()
152 .next()
153 .expect("separator length checked to be exactly 1 above");
154
155 Ok(NamingRules {
156 case,
157 separator,
158 max_length: section.max_length,
159 strip_special: section.strip_special,
160 })
161 }
162
163 #[cfg(test)]
164 mod tests {
165 use super::*;
166
167 const SP404_TOML: &str = r#"
168 [device]
169 name = "SP-404 MKII"
170 manufacturer = "Roland"
171 version = "1.0"
172
173 [audio]
174 formats = ["wav"]
175 sample_rates = [44100, 48000]
176 bit_depths = [16, 24]
177 channels = "both"
178
179 [naming]
180 case = "upper"
181 separator = "_"
182 max_length = 12
183 strip_special = true
184
185 [limits]
186 max_file_size_bytes = 134217728
187 "#;
188
189 #[test]
190 fn parse_sp404_manifest() {
191 let manifest = parse_manifest(SP404_TOML).unwrap();
192 assert_eq!(manifest.device.name, "SP-404 MKII");
193 assert_eq!(manifest.device.manufacturer, "Roland");
194 assert_eq!(manifest.audio.formats, vec!["wav"]);
195 assert_eq!(manifest.audio.sample_rates, vec![44100, 48000]);
196 assert!(manifest.naming.is_some());
197 assert!(manifest.limits.is_some());
198 }
199
200 #[test]
201 fn manifest_to_profile_roundtrip() {
202 let manifest = parse_manifest(SP404_TOML).unwrap();
203 let profile = manifest_to_profile(&manifest).unwrap();
204 assert_eq!(profile.name, "SP-404 MKII");
205 assert_eq!(profile.audio.formats, vec![ExportFormat::Wav]);
206 assert_eq!(profile.audio.channels, ChannelConstraint::Both);
207 assert_eq!(
208 profile.naming.as_ref().unwrap().case,
209 NamingCase::Upper
210 );
211 assert_eq!(
212 profile.limits.as_ref().unwrap().max_file_size_bytes,
213 Some(134_217_728)
214 );
215 }
216
217 #[test]
218 fn invalid_format_errors() {
219 let result = parse_format("mp3");
220 assert!(result.is_err());
221 }
222
223 #[test]
224 fn aiff_format_parses() {
225 assert!(matches!(parse_format("aiff"), Ok(ExportFormat::Aiff)));
226 assert!(matches!(parse_format("aif"), Ok(ExportFormat::Aiff)));
227 assert!(matches!(parse_format("AIFF"), Ok(ExportFormat::Aiff)));
228 }
229
230 #[test]
231 fn invalid_channels_errors() {
232 let result = parse_channels("surround");
233 assert!(result.is_err());
234 }
235
236 #[test]
237 fn manifest_with_category_and_notes() {
238 let toml = r#"
239 [device]
240 name = "OP-1"
241 manufacturer = "Teenage Engineering"
242 version = "1.0"
243 category = "synthesizer"
244 notes = "Mounts as USB drive when held with shift on boot."
245
246 [audio]
247 formats = ["aiff"]
248 sample_rates = [44100]
249 bit_depths = [16]
250 channels = "mono"
251 "#;
252 let manifest = parse_manifest(toml).unwrap();
253 let profile = manifest_to_profile(&manifest).unwrap();
254 assert_eq!(profile.category.as_deref(), Some("synthesizer"));
255 assert_eq!(
256 profile.notes.as_deref(),
257 Some("Mounts as USB drive when held with shift on boot.")
258 );
259 }
260
261 #[test]
262 fn minimal_manifest_no_naming_no_limits() {
263 let toml = r#"
264 [device]
265 name = "Generic"
266 manufacturer = "Unknown"
267 version = "1.0"
268
269 [audio]
270 formats = ["wav"]
271 sample_rates = [44100]
272 bit_depths = [16]
273 channels = "stereo"
274 "#;
275 let manifest = parse_manifest(toml).unwrap();
276 let profile = manifest_to_profile(&manifest).unwrap();
277 assert!(profile.naming.is_none());
278 assert!(profile.limits.is_none());
279 }
280 }
281