//! TOML manifest parsing and conversion to `DeviceProfile`. use serde::Deserialize; use tracing::instrument; use audiofiles_core::export::profile::{ AudioConstraints, ChannelConstraint, DeviceProfile, ExportConstraints, NamingCase, NamingRules, }; use audiofiles_core::export::ExportFormat; use crate::error::PluginError; /// Raw TOML manifest structure. #[derive(Debug, Deserialize)] pub struct PluginManifest { pub device: DeviceSection, pub audio: AudioSection, pub naming: Option, pub limits: Option, pub hooks: Option, } #[derive(Debug, Deserialize)] pub struct DeviceSection { pub name: String, pub manufacturer: String, pub version: String, #[serde(default)] pub category: Option, #[serde(default)] pub notes: Option, } #[derive(Debug, Deserialize)] pub struct AudioSection { pub formats: Vec, pub sample_rates: Vec, pub bit_depths: Vec, pub channels: String, } #[derive(Debug, Deserialize)] pub struct NamingSection { pub case: String, pub separator: String, pub max_length: usize, pub strip_special: bool, } #[derive(Debug, Deserialize)] pub struct LimitsSection { pub max_file_size_bytes: Option, pub max_sample_count: Option, } #[derive(Debug, Deserialize)] pub struct HooksSection { pub validate_sample: Option, pub transform_filename: Option, pub pre_export: Option, pub post_export: Option, } /// Parse a TOML string into a `PluginManifest`. #[instrument(skip_all)] pub fn parse_manifest(toml_str: &str) -> Result { toml::from_str(toml_str).map_err(|e| PluginError::ManifestParse(e.to_string())) } /// Convert a parsed manifest into a `DeviceProfile`. #[instrument(skip_all)] pub fn manifest_to_profile(manifest: &PluginManifest) -> Result { let formats = manifest .audio .formats .iter() .map(|f| parse_format(f)) .collect::, _>>()?; let channels = parse_channels(&manifest.audio.channels)?; let naming = manifest .naming .as_ref() .map(parse_naming) .transpose()?; let limits = manifest.limits.as_ref().map(|l| ExportConstraints { max_file_size_bytes: l.max_file_size_bytes, max_sample_count: l.max_sample_count, }); Ok(DeviceProfile { name: manifest.device.name.clone(), manufacturer: manifest.device.manufacturer.clone(), category: manifest.device.category.clone(), notes: manifest.device.notes.clone(), audio: AudioConstraints { formats, sample_rates: manifest.audio.sample_rates.clone(), bit_depths: manifest.audio.bit_depths.clone(), channels, }, naming, limits, }) } fn parse_format(s: &str) -> Result { match s.to_lowercase().as_str() { "wav" => Ok(ExportFormat::Wav), "aiff" | "aif" => Ok(ExportFormat::Aiff), other => Err(PluginError::ManifestInvalid(format!( "unknown format: {other}" ))), } } fn parse_channels(s: &str) -> Result { match s.to_lowercase().as_str() { "mono" => Ok(ChannelConstraint::Mono), "stereo" => Ok(ChannelConstraint::Stereo), "both" => Ok(ChannelConstraint::Both), other => Err(PluginError::ManifestInvalid(format!( "unknown channel constraint: {other}" ))), } } fn parse_naming(section: &NamingSection) -> Result { let case = match section.case.to_lowercase().as_str() { "lower" => NamingCase::Lower, "upper" => NamingCase::Upper, "original" => NamingCase::Original, other => { return Err(PluginError::ManifestInvalid(format!( "unknown naming case: {other}" ))); } }; if section.separator.chars().count() != 1 { return Err(PluginError::ManifestInvalid(format!( "separator must be exactly one character, got {:?}", section.separator, ))); } let separator = section .separator .chars() .next() .expect("separator length checked to be exactly 1 above"); Ok(NamingRules { case, separator, max_length: section.max_length, strip_special: section.strip_special, }) } #[cfg(test)] mod tests { use super::*; const SP404_TOML: &str = r#" [device] name = "SP-404 MKII" manufacturer = "Roland" version = "1.0" [audio] formats = ["wav"] sample_rates = [44100, 48000] bit_depths = [16, 24] channels = "both" [naming] case = "upper" separator = "_" max_length = 12 strip_special = true [limits] max_file_size_bytes = 134217728 "#; #[test] fn parse_sp404_manifest() { let manifest = parse_manifest(SP404_TOML).unwrap(); assert_eq!(manifest.device.name, "SP-404 MKII"); assert_eq!(manifest.device.manufacturer, "Roland"); assert_eq!(manifest.audio.formats, vec!["wav"]); assert_eq!(manifest.audio.sample_rates, vec![44100, 48000]); assert!(manifest.naming.is_some()); assert!(manifest.limits.is_some()); } #[test] fn manifest_to_profile_roundtrip() { let manifest = parse_manifest(SP404_TOML).unwrap(); let profile = manifest_to_profile(&manifest).unwrap(); assert_eq!(profile.name, "SP-404 MKII"); assert_eq!(profile.audio.formats, vec![ExportFormat::Wav]); assert_eq!(profile.audio.channels, ChannelConstraint::Both); assert_eq!( profile.naming.as_ref().unwrap().case, NamingCase::Upper ); assert_eq!( profile.limits.as_ref().unwrap().max_file_size_bytes, Some(134_217_728) ); } #[test] fn invalid_format_errors() { let result = parse_format("mp3"); assert!(result.is_err()); } #[test] fn aiff_format_parses() { assert!(matches!(parse_format("aiff"), Ok(ExportFormat::Aiff))); assert!(matches!(parse_format("aif"), Ok(ExportFormat::Aiff))); assert!(matches!(parse_format("AIFF"), Ok(ExportFormat::Aiff))); } #[test] fn invalid_channels_errors() { let result = parse_channels("surround"); assert!(result.is_err()); } #[test] fn manifest_with_category_and_notes() { let toml = r#" [device] name = "OP-1" manufacturer = "Teenage Engineering" version = "1.0" category = "synthesizer" notes = "Mounts as USB drive when held with shift on boot." [audio] formats = ["aiff"] sample_rates = [44100] bit_depths = [16] channels = "mono" "#; let manifest = parse_manifest(toml).unwrap(); let profile = manifest_to_profile(&manifest).unwrap(); assert_eq!(profile.category.as_deref(), Some("synthesizer")); assert_eq!( profile.notes.as_deref(), Some("Mounts as USB drive when held with shift on boot.") ); } #[test] fn minimal_manifest_no_naming_no_limits() { let toml = r#" [device] name = "Generic" manufacturer = "Unknown" version = "1.0" [audio] formats = ["wav"] sample_rates = [44100] bit_depths = [16] channels = "stereo" "#; let manifest = parse_manifest(toml).unwrap(); let profile = manifest_to_profile(&manifest).unwrap(); assert!(profile.naming.is_none()); assert!(profile.limits.is_none()); } }