Skip to main content

max / audiofiles

7.4 KB · 277 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.separator.chars().next().unwrap();
150
151 Ok(NamingRules {
152 case,
153 separator,
154 max_length: section.max_length,
155 strip_special: section.strip_special,
156 })
157 }
158
159 #[cfg(test)]
160 mod tests {
161 use super::*;
162
163 const SP404_TOML: &str = r#"
164 [device]
165 name = "SP-404 MKII"
166 manufacturer = "Roland"
167 version = "1.0"
168
169 [audio]
170 formats = ["wav"]
171 sample_rates = [44100, 48000]
172 bit_depths = [16, 24]
173 channels = "both"
174
175 [naming]
176 case = "upper"
177 separator = "_"
178 max_length = 12
179 strip_special = true
180
181 [limits]
182 max_file_size_bytes = 134217728
183 "#;
184
185 #[test]
186 fn parse_sp404_manifest() {
187 let manifest = parse_manifest(SP404_TOML).unwrap();
188 assert_eq!(manifest.device.name, "SP-404 MKII");
189 assert_eq!(manifest.device.manufacturer, "Roland");
190 assert_eq!(manifest.audio.formats, vec!["wav"]);
191 assert_eq!(manifest.audio.sample_rates, vec![44100, 48000]);
192 assert!(manifest.naming.is_some());
193 assert!(manifest.limits.is_some());
194 }
195
196 #[test]
197 fn manifest_to_profile_roundtrip() {
198 let manifest = parse_manifest(SP404_TOML).unwrap();
199 let profile = manifest_to_profile(&manifest).unwrap();
200 assert_eq!(profile.name, "SP-404 MKII");
201 assert_eq!(profile.audio.formats, vec![ExportFormat::Wav]);
202 assert_eq!(profile.audio.channels, ChannelConstraint::Both);
203 assert_eq!(
204 profile.naming.as_ref().unwrap().case,
205 NamingCase::Upper
206 );
207 assert_eq!(
208 profile.limits.as_ref().unwrap().max_file_size_bytes,
209 Some(134_217_728)
210 );
211 }
212
213 #[test]
214 fn invalid_format_errors() {
215 let result = parse_format("mp3");
216 assert!(result.is_err());
217 }
218
219 #[test]
220 fn aiff_format_parses() {
221 assert!(matches!(parse_format("aiff"), Ok(ExportFormat::Aiff)));
222 assert!(matches!(parse_format("aif"), Ok(ExportFormat::Aiff)));
223 assert!(matches!(parse_format("AIFF"), Ok(ExportFormat::Aiff)));
224 }
225
226 #[test]
227 fn invalid_channels_errors() {
228 let result = parse_channels("surround");
229 assert!(result.is_err());
230 }
231
232 #[test]
233 fn manifest_with_category_and_notes() {
234 let toml = r#"
235 [device]
236 name = "OP-1"
237 manufacturer = "Teenage Engineering"
238 version = "1.0"
239 category = "synthesizer"
240 notes = "Mounts as USB drive when held with shift on boot."
241
242 [audio]
243 formats = ["aiff"]
244 sample_rates = [44100]
245 bit_depths = [16]
246 channels = "mono"
247 "#;
248 let manifest = parse_manifest(toml).unwrap();
249 let profile = manifest_to_profile(&manifest).unwrap();
250 assert_eq!(profile.category.as_deref(), Some("synthesizer"));
251 assert_eq!(
252 profile.notes.as_deref(),
253 Some("Mounts as USB drive when held with shift on boot.")
254 );
255 }
256
257 #[test]
258 fn minimal_manifest_no_naming_no_limits() {
259 let toml = r#"
260 [device]
261 name = "Generic"
262 manufacturer = "Unknown"
263 version = "1.0"
264
265 [audio]
266 formats = ["wav"]
267 sample_rates = [44100]
268 bit_depths = [16]
269 channels = "stereo"
270 "#;
271 let manifest = parse_manifest(toml).unwrap();
272 let profile = manifest_to_profile(&manifest).unwrap();
273 assert!(profile.naming.is_none());
274 assert!(profile.limits.is_none());
275 }
276 }
277