//! Pattern and project sharing via compact text strings. //! //! Export: data → MessagePack → Brotli → base64 URL-safe → prefix //! Import: strip prefix → base64 decode → Brotli decompress → MessagePack → data use base64::engine::general_purpose::URL_SAFE_NO_PAD; use base64::Engine; use crate::{Bank, Pattern}; const PATTERN_PREFIX: &str = "cgr:"; const BANK_PREFIX: &str = "cgrb:"; /// Error during pattern or bank import/export. #[derive(Debug)] pub enum ShareError { InvalidPrefix, Base64(base64::DecodeError), Decompress(std::io::Error), Deserialize(rmp_serde::decode::Error), Serialize(rmp_serde::encode::Error), Compress(std::io::Error), } impl std::fmt::Display for ShareError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::InvalidPrefix => write!(f, "missing cgr:/cgrb: prefix"), Self::Base64(e) => write!(f, "base64: {e}"), Self::Decompress(e) => write!(f, "decompress: {e}"), Self::Deserialize(e) => write!(f, "deserialize: {e}"), Self::Serialize(e) => write!(f, "serialize: {e}"), Self::Compress(e) => write!(f, "compress: {e}"), } } } fn compress(data: &[u8]) -> Result, ShareError> { let mut output = Vec::new(); let params = brotli::enc::BrotliEncoderParams { quality: 11, lgwin: 22, lgblock: 0, ..Default::default() }; brotli::BrotliCompress(&mut &data[..], &mut output, ¶ms).map_err(ShareError::Compress)?; Ok(output) } fn decompress(data: &[u8]) -> Result, ShareError> { let mut output = Vec::new(); brotli::BrotliDecompress(&mut &data[..], &mut output).map_err(ShareError::Decompress)?; Ok(output) } fn encode(value: &T, prefix: &str) -> Result { let packed = rmp_serde::to_vec_named(value).map_err(ShareError::Serialize)?; let compressed = compress(&packed)?; let encoded = URL_SAFE_NO_PAD.encode(&compressed); Ok(format!("{prefix}{encoded}")) } fn decode(text: &str, prefix: &str) -> Result { let text = text.trim(); let payload = text.strip_prefix(prefix).ok_or(ShareError::InvalidPrefix)?; let compressed = URL_SAFE_NO_PAD.decode(payload).map_err(ShareError::Base64)?; let packed = decompress(&compressed)?; rmp_serde::from_slice(&packed).map_err(ShareError::Deserialize) } /// Encode a pattern as a shareable `cgr:` string. pub fn export(pattern: &Pattern) -> Result { encode(pattern, PATTERN_PREFIX) } /// Decode a `cgr:` string back into a pattern. pub fn import(text: &str) -> Result { decode(text, PATTERN_PREFIX) } /// Encode a bank as a shareable `cgrb:` string. pub fn export_bank(bank: &Bank) -> Result { encode(bank, BANK_PREFIX) } /// Decode a `cgrb:` string back into a bank. pub fn import_bank(text: &str) -> Result { decode(text, BANK_PREFIX) } #[cfg(test)] mod tests { use super::*; use crate::Step; #[test] fn roundtrip_empty() { let pattern = Pattern::default(); let encoded = export(&pattern).unwrap(); assert!(encoded.starts_with("cgr:")); let decoded = import(&encoded).unwrap(); assert_eq!(decoded.length, pattern.length); assert_eq!(decoded.steps.len(), pattern.steps.len()); } #[test] fn roundtrip_with_steps() { let mut pattern = Pattern::default(); pattern.steps[0] = Step { active: true, script: "kick 60 note".to_string(), source: None, name: Some("kick".to_string()), }; pattern.steps[1] = Step { active: false, script: "snare".to_string(), source: None, name: None, }; pattern.steps[3] = Step { active: true, script: String::new(), source: Some(0), name: None, }; pattern.length = 8; pattern.name = Some("Test".to_string()); let encoded = export(&pattern).unwrap(); let decoded = import(&encoded).unwrap(); assert_eq!(decoded.length, 8); assert_eq!(decoded.name.as_deref(), Some("Test")); assert_eq!(decoded.steps[0].script, "kick 60 note"); assert_eq!(decoded.steps[0].name.as_deref(), Some("kick")); assert!(!decoded.steps[1].active); assert_eq!(decoded.steps[1].script, "snare"); assert_eq!(decoded.steps[3].source, Some(0)); } #[test] fn bad_prefix() { assert!(matches!(import("xxx:abc"), Err(ShareError::InvalidPrefix))); } #[test] fn bad_base64() { assert!(matches!(import("cgr:!!!"), Err(ShareError::Base64(_)))); } #[test] fn whitespace_trimming() { let pattern = Pattern::default(); let encoded = export(&pattern).unwrap(); let padded = format!(" {encoded} \n"); let decoded = import(&padded).unwrap(); assert_eq!(decoded.length, pattern.length); } #[test] fn msgpack_brotli_smaller_than_json_deflate() { let mut pattern = Pattern::default(); for i in 0..16 { pattern.steps[i] = Step { active: true, script: format!("kick {i} note 0.5 dur"), source: None, name: Some(format!("step_{i}")), }; } pattern.length = 16; // Current (msgpack+brotli) let new_encoded = export(&pattern).unwrap(); // Old pipeline (json+deflate) for comparison use std::io::Write; let json = serde_json::to_vec(&pattern).unwrap(); let mut encoder = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::best()); encoder.write_all(&json).unwrap(); let old_compressed = encoder.finish().unwrap(); let old_encoded = format!("cgr:{}", URL_SAFE_NO_PAD.encode(&old_compressed)); assert!( new_encoded.len() < old_encoded.len(), "msgpack+brotli ({}) should be smaller than json+deflate ({})", new_encoded.len(), old_encoded.len() ); } #[test] fn roundtrip_bank() { let mut bank = Bank::default(); bank.patterns[0].steps[0] = Step { active: true, script: "kick 60 note".to_string(), source: None, name: Some("kick".to_string()), }; bank.patterns[0].length = 8; bank.name = Some("Drums".to_string()); let encoded = export_bank(&bank).unwrap(); assert!(encoded.starts_with("cgrb:")); let decoded = import_bank(&encoded).unwrap(); assert_eq!(decoded.name.as_deref(), Some("Drums")); assert_eq!(decoded.patterns[0].length, 8); assert_eq!(decoded.patterns[0].steps[0].script, "kick 60 note"); } }