//! Export pipeline: collect VFS items, optionally convert audio, write to filesystem. pub mod convert; pub mod decode; mod dither; pub mod encode; pub mod encode_aiff; pub mod profile; mod resolve; mod runner; pub mod sanitize; use std::path::PathBuf; use crate::db::Database; use crate::error::Result; use crate::id_types::{NodeId, VfsId}; use self::profile::NamingRules; // Re-export submodule public API so callers can use `export::run_export`, etc. pub use self::resolve::resolve_output_names; pub use self::runner::run_export; /// Output format for exported files. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum ExportFormat { /// Copy the original file as-is. Original, /// Decode and re-encode as WAV. Wav, /// Decode and re-encode as AIFF. Aiff, } /// Target channel count for export. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum ExportChannels { /// Keep original channel layout. Original, /// Mix down to mono. Mono, /// Upmix/downmix to stereo. Stereo, } /// Configuration for an export operation. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct ExportConfig { pub format: ExportFormat, /// Target sample rate, or None to keep original. pub sample_rate: Option, /// Target bit depth (16 or 24), or None to keep original. Only used with WAV/AIFF format. pub bit_depth: Option, pub channels: ExportChannels, /// Rename pattern for output filenames, or None to use original VFS names. pub naming_pattern: Option, /// If true, flatten all files into destination root (no subdirectories). pub flatten: bool, /// If true, write a `.audiofiles.json` sidecar alongside each exported file. pub metadata_sidecar: bool, /// Destination directory on the real filesystem. pub destination: PathBuf, /// Device profile name to apply constraints from (e.g. "SP-404 MKII"). /// When set, the export pipeline applies the profile's audio, naming, and size constraints. #[serde(default)] pub device_profile: Option, /// Naming rules resolved from the device profile. Applied after rename pattern. #[serde(default)] pub naming_rules: Option, /// Maximum file size in bytes, resolved from the device profile. /// Files exceeding this limit are removed and reported as errors. #[serde(default)] pub max_file_size_bytes: Option, /// Pre-computed output filenames (one per item, in order). /// When set, `resolve_output_names()` returns these directly, skipping /// pattern/sanitize/dedup. Used by the backend to inject hook-transformed names. #[serde(default)] pub name_overrides: Option>, } /// A single item to export, collected from the VFS tree. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct ExportItem { pub hash: crate::SampleHash, pub ext: String, /// Path relative to the export root (preserving VFS structure). pub relative_path: PathBuf, /// Display name (VFS node name). pub name: String, // Analysis fields for rename context pub bpm: Option, pub musical_key: Option, pub classification: Option, pub duration: Option, /// Tags associated with this sample (populated by `enrich_with_tags`). pub tags: Vec, /// Original file path for loose-files mode samples (populated from samples.source_path). /// When set, the export runner uses this path instead of the store. pub source_path: Option, } /// Summary of a completed export. #[derive(Debug)] pub struct ExportSummary { pub total: usize, pub errors: Vec<(String, String)>, } /// Collect all sample nodes under a VFS subtree, building relative paths. /// /// If `parent_id` is `None`, collects all samples in the entire VFS. /// If `parent_id` is `Some`, collects samples under that directory (recursive). pub fn collect_export_items( db: &Database, vfs_id: VfsId, parent_id: Option, ) -> Result> { // Use a recursive CTE to walk the subtree and build relative paths. // When parent_id is None, we start from root-level nodes. let sql = if parent_id.is_some() { "WITH RECURSIVE tree(id, path) AS ( SELECT n.id, n.name FROM vfs_nodes n WHERE n.vfs_id = ?1 AND n.parent_id = ?2 UNION ALL SELECT n.id, t.path || '/' || n.name FROM vfs_nodes n JOIN tree t ON n.parent_id = t.id ) SELECT n.sample_hash, s.file_extension, t.path, n.name, a.bpm, a.musical_key, a.classification, COALESCE(a.duration, s.duration), s.source_path FROM tree t JOIN vfs_nodes n ON n.id = t.id LEFT JOIN samples s ON n.sample_hash = s.hash LEFT JOIN audio_analysis a ON n.sample_hash = a.hash WHERE n.node_type = 'sample' AND n.sample_hash IS NOT NULL AND s.deleted_at IS NULL ORDER BY t.path" } else { "WITH RECURSIVE tree(id, path) AS ( SELECT n.id, n.name FROM vfs_nodes n WHERE n.vfs_id = ?1 AND n.parent_id IS NULL UNION ALL SELECT n.id, t.path || '/' || n.name FROM vfs_nodes n JOIN tree t ON n.parent_id = t.id ) SELECT n.sample_hash, s.file_extension, t.path, n.name, a.bpm, a.musical_key, a.classification, COALESCE(a.duration, s.duration), s.source_path FROM tree t JOIN vfs_nodes n ON n.id = t.id LEFT JOIN samples s ON n.sample_hash = s.hash LEFT JOIN audio_analysis a ON n.sample_hash = a.hash WHERE n.node_type = 'sample' AND n.sample_hash IS NOT NULL AND s.deleted_at IS NULL ORDER BY t.path" }; let mut stmt = db.conn().prepare(sql)?; let rows = if let Some(pid) = parent_id { stmt.query_map(rusqlite::params![vfs_id, pid], map_export_item)? } else { stmt.query_map(rusqlite::params![vfs_id], map_export_item)? }; Ok(rows .filter_map(|r| r.ok()) .flatten() .collect()) } fn map_export_item(row: &rusqlite::Row) -> rusqlite::Result> { let hash: Option = row.get(0)?; let ext: Option = row.get(1)?; let path: String = row.get(2)?; let name: String = row.get(3)?; let (hash, ext) = match (hash, ext) { (Some(h), Some(e)) => (h, e), _ => return Ok(None), }; let source_path: Option = row.get(8)?; Ok(Some(ExportItem { hash: crate::SampleHash::new(hash), ext, relative_path: PathBuf::from(path), name, bpm: row.get(4)?, musical_key: row.get(5)?, classification: row.get(6)?, duration: row.get(7)?, tags: Vec::new(), source_path: source_path.map(PathBuf::from), })) } /// Populate the `tags` field on each export item by querying the database. pub fn enrich_with_tags(db: &Database, items: &mut [ExportItem]) { if items.is_empty() { return; } // Batch query: fetch all tags for all hashes in one statement. let hashes: Vec = items.iter().map(|i| i.hash.to_string()).collect(); let mut tag_map = std::collections::HashMap::>::new(); // SQLite variable limit is 999 in older builds; chunk to stay safe. for chunk in hashes.chunks(500) { let placeholders: String = chunk.iter().enumerate() .map(|(i, _)| format!("?{}", i + 1)) .collect::>() .join(", "); let sql = format!( "SELECT sample_hash, tag FROM tags WHERE sample_hash IN ({}) ORDER BY tag", placeholders, ); if let Ok(mut stmt) = db.conn().prepare(&sql) { let params: Vec<&dyn rusqlite::types::ToSql> = chunk .iter() .map(|h| h as &dyn rusqlite::types::ToSql) .collect(); if let Ok(rows) = stmt.query_map(params.as_slice(), |row| { Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) }) { for row in rows.flatten() { tag_map.entry(row.0).or_default().push(row.1); } } } } for item in items.iter_mut() { if let Some(tags) = tag_map.remove(item.hash.as_str()) { item.tags = tags; } } } #[cfg(test)] mod tests { use super::*; use crate::db::Database; use crate::store::SampleStore; use crate::vfs; use std::fs; use std::io::Write; use std::path::Path; fn setup_vfs_with_samples(db: &Database, store: &SampleStore, dir: &Path) -> crate::VfsId { let vfs_id = vfs::create_vfs(db, "TestVFS").unwrap(); // Create a real audio file in a temp location, then import it let wav_path = dir.join("kick.wav"); write_test_wav(&wav_path, 1, 44100, &[0.5, -0.5, 0.25, 0.0]); let hash = store.import(&wav_path, db).unwrap(); // Create directory structure in VFS let drums_id = vfs::create_directory(db, vfs_id, None, "Drums").unwrap(); vfs::create_sample_link(db, vfs_id, Some(drums_id), "kick.wav", &hash).unwrap(); vfs_id } fn write_test_wav(path: &Path, channels: u16, sample_rate: u32, samples: &[f32]) { let bytes_per_sample = 4u16; let block_align = channels * bytes_per_sample; let data_size = (samples.len() as u32) * 4; let file_size = 36 + data_size; let mut buf = Vec::with_capacity(44 + data_size as usize); buf.extend_from_slice(b"RIFF"); buf.extend_from_slice(&file_size.to_le_bytes()); buf.extend_from_slice(b"WAVE"); buf.extend_from_slice(b"fmt "); buf.extend_from_slice(&16u32.to_le_bytes()); buf.extend_from_slice(&3u16.to_le_bytes()); buf.extend_from_slice(&channels.to_le_bytes()); buf.extend_from_slice(&sample_rate.to_le_bytes()); buf.extend_from_slice(&(sample_rate * block_align as u32).to_le_bytes()); buf.extend_from_slice(&block_align.to_le_bytes()); buf.extend_from_slice(&(bytes_per_sample * 8).to_le_bytes()); buf.extend_from_slice(b"data"); buf.extend_from_slice(&data_size.to_le_bytes()); for &s in samples { buf.extend_from_slice(&s.to_le_bytes()); } let mut file = fs::File::create(path).unwrap(); file.write_all(&buf).unwrap(); } #[test] fn collect_export_items_builds_relative_paths() { let dir = tempfile::tempdir().unwrap(); let db = Database::open_in_memory().unwrap(); let store = SampleStore::new(dir.path().join("store")).unwrap(); let vfs_id = setup_vfs_with_samples(&db, &store, dir.path()); let items = collect_export_items(&db, vfs_id, None).unwrap(); assert_eq!(items.len(), 1); assert_eq!(items[0].name, "kick.wav"); assert_eq!(items[0].relative_path, PathBuf::from("Drums/kick.wav")); } #[test] fn export_single_original_copies_file() { let dir = tempfile::tempdir().unwrap(); let db = Database::open_in_memory().unwrap(); let store = SampleStore::new(dir.path().join("store")).unwrap(); let vfs_id = setup_vfs_with_samples(&db, &store, dir.path()); let items = collect_export_items(&db, vfs_id, None).unwrap(); let dest_dir = dir.path().join("export"); let config = ExportConfig { format: ExportFormat::Original, sample_rate: None, bit_depth: None, channels: ExportChannels::Original, naming_pattern: None, flatten: false, metadata_sidecar: false, destination: dest_dir.clone(), device_profile: None, naming_rules: None, max_file_size_bytes: None, name_overrides: None, }; let summary = run_export(&items, &config, &store, |_, _, _| true).unwrap(); assert_eq!(summary.total, 1); assert!(summary.errors.is_empty()); // Verify the file was copied with directory structure let exported = dest_dir.join("Drums").join("kick.wav"); assert!(exported.exists()); // Verify content matches (hardlink or copy) let source_path = store.sample_path(&items[0].hash, &items[0].ext).unwrap(); let source_bytes = fs::read(&source_path).unwrap(); let export_bytes = fs::read(&exported).unwrap(); assert_eq!(source_bytes, export_bytes); } #[test] fn export_single_wav_16bit() { let dir = tempfile::tempdir().unwrap(); let db = Database::open_in_memory().unwrap(); let store = SampleStore::new(dir.path().join("store")).unwrap(); let vfs_id = setup_vfs_with_samples(&db, &store, dir.path()); let items = collect_export_items(&db, vfs_id, None).unwrap(); let dest_dir = dir.path().join("export"); let config = ExportConfig { format: ExportFormat::Wav, sample_rate: Some(44100), bit_depth: Some(16), channels: ExportChannels::Original, naming_pattern: None, flatten: false, metadata_sidecar: false, destination: dest_dir.clone(), device_profile: None, naming_rules: None, max_file_size_bytes: None, name_overrides: None, }; let summary = run_export(&items, &config, &store, |_, _, _| true).unwrap(); assert_eq!(summary.total, 1); assert!(summary.errors.is_empty()); // Verify the WAV was created let exported = dest_dir.join("Drums").join("kick.wav"); assert!(exported.exists()); // Verify it's a valid 16-bit WAV let reader = hound::WavReader::open(&exported).unwrap(); assert_eq!(reader.spec().bits_per_sample, 16); assert_eq!(reader.spec().sample_rate, 44100); } #[test] fn export_single_wav_24bit() { let dir = tempfile::tempdir().unwrap(); let db = Database::open_in_memory().unwrap(); let store = SampleStore::new(dir.path().join("store")).unwrap(); let vfs_id = setup_vfs_with_samples(&db, &store, dir.path()); let items = collect_export_items(&db, vfs_id, None).unwrap(); let dest_dir = dir.path().join("export"); let config = ExportConfig { format: ExportFormat::Wav, sample_rate: None, bit_depth: Some(24), channels: ExportChannels::Mono, naming_pattern: None, flatten: false, metadata_sidecar: false, destination: dest_dir.clone(), device_profile: None, naming_rules: None, max_file_size_bytes: None, name_overrides: None, }; let summary = run_export(&items, &config, &store, |_, _, _| true).unwrap(); assert!(summary.errors.is_empty()); let exported = dest_dir.join("Drums").join("kick.wav"); let reader = hound::WavReader::open(&exported).unwrap(); assert_eq!(reader.spec().bits_per_sample, 24); assert_eq!(reader.spec().channels, 1); } #[test] fn export_flat_with_pattern() { let dir = tempfile::tempdir().unwrap(); let db = Database::open_in_memory().unwrap(); let store = SampleStore::new(dir.path().join("store")).unwrap(); let vfs_id = setup_vfs_with_samples(&db, &store, dir.path()); let items = collect_export_items(&db, vfs_id, None).unwrap(); let dest_dir = dir.path().join("export_flat"); let config = ExportConfig { format: ExportFormat::Original, sample_rate: None, bit_depth: None, channels: ExportChannels::Original, naming_pattern: Some("{nn}_{name}".to_string()), flatten: true, metadata_sidecar: false, destination: dest_dir.clone(), device_profile: None, naming_rules: None, max_file_size_bytes: None, name_overrides: None, }; let summary = run_export(&items, &config, &store, |_, _, _| true).unwrap(); assert!(summary.errors.is_empty()); // Should be flat (no Drums/ subdirectory) let exported = dest_dir.join("01_kick.wav"); assert!(exported.exists(), "expected 01_kick.wav in flat export"); assert!(!dest_dir.join("Drums").exists()); } #[test] fn split_name_ext_works() { use crate::util::split_name_ext; assert_eq!(split_name_ext("kick.wav"), ("kick".into(), "wav".into())); assert_eq!(split_name_ext("noext"), ("noext".into(), "".into())); assert_eq!( split_name_ext("archive.tar.gz"), ("archive.tar".into(), "gz".into()) ); } #[test] fn export_with_sidecar_writes_json() { let dir = tempfile::tempdir().unwrap(); let db = Database::open_in_memory().unwrap(); let store = SampleStore::new(dir.path().join("store")).unwrap(); let vfs_id = setup_vfs_with_samples(&db, &store, dir.path()); let mut items = collect_export_items(&db, vfs_id, None).unwrap(); enrich_with_tags(&db, &mut items); let dest_dir = dir.path().join("export_sidecar"); let config = ExportConfig { format: ExportFormat::Original, sample_rate: None, bit_depth: None, channels: ExportChannels::Original, naming_pattern: None, flatten: false, metadata_sidecar: true, destination: dest_dir.clone(), device_profile: None, naming_rules: None, max_file_size_bytes: None, name_overrides: None, }; let summary = run_export(&items, &config, &store, |_, _, _| true).unwrap(); assert!(summary.errors.is_empty()); let sidecar = dest_dir.join("Drums").join("kick.wav.audiofiles.json"); assert!(sidecar.exists(), "sidecar file should exist"); let content: serde_json::Value = serde_json::from_str(&fs::read_to_string(&sidecar).unwrap()).unwrap(); assert_eq!(content["name"], "kick.wav"); assert!(content["hash"].is_string()); } #[test] fn hardlink_or_copy_works() { let dir = tempfile::tempdir().unwrap(); let db = Database::open_in_memory().unwrap(); let store = SampleStore::new(dir.path().join("store")).unwrap(); let vfs_id = setup_vfs_with_samples(&db, &store, dir.path()); let items = collect_export_items(&db, vfs_id, None).unwrap(); let dest_dir = dir.path().join("export_hl"); let config = ExportConfig { format: ExportFormat::Original, sample_rate: None, bit_depth: None, channels: ExportChannels::Original, naming_pattern: None, flatten: false, metadata_sidecar: false, destination: dest_dir.clone(), device_profile: None, naming_rules: None, max_file_size_bytes: None, name_overrides: None, }; let summary = run_export(&items, &config, &store, |_, _, _| true).unwrap(); assert!(summary.errors.is_empty()); let exported = dest_dir.join("Drums").join("kick.wav"); assert!(exported.exists()); let source_path = store.sample_path(&items[0].hash, &items[0].ext).unwrap(); let source_bytes = fs::read(&source_path).unwrap(); let export_bytes = fs::read(&exported).unwrap(); assert_eq!(source_bytes, export_bytes); } #[test] fn enrich_with_tags_populates() { let dir = tempfile::tempdir().unwrap(); let db = Database::open_in_memory().unwrap(); let store = SampleStore::new(dir.path().join("store")).unwrap(); let vfs_id = setup_vfs_with_samples(&db, &store, dir.path()); let mut items = collect_export_items(&db, vfs_id, None).unwrap(); assert!(items[0].tags.is_empty()); // Add tags crate::tags::add_tag(&db, &items[0].hash, "kick").unwrap(); crate::tags::add_tag(&db, &items[0].hash, "drums").unwrap(); enrich_with_tags(&db, &mut items); assert_eq!(items[0].tags.len(), 2); assert!(items[0].tags.contains(&"drums".to_string())); assert!(items[0].tags.contains(&"kick".to_string())); } #[test] fn export_with_naming_rules_sanitizes() { use crate::export::profile::{NamingCase, NamingRules}; let dir = tempfile::tempdir().unwrap(); let db = Database::open_in_memory().unwrap(); let store = SampleStore::new(dir.path().join("store")).unwrap(); let vfs_id = setup_vfs_with_samples(&db, &store, dir.path()); let items = collect_export_items(&db, vfs_id, None).unwrap(); let dest_dir = dir.path().join("export_sanitize"); let config = ExportConfig { format: ExportFormat::Original, sample_rate: None, bit_depth: None, channels: ExportChannels::Original, naming_pattern: None, flatten: true, metadata_sidecar: false, destination: dest_dir.clone(), device_profile: None, naming_rules: Some(NamingRules { case: NamingCase::Upper, separator: '_', max_length: 8, strip_special: true, }), max_file_size_bytes: None, name_overrides: None, }; let summary = run_export(&items, &config, &store, |_, _, _| true).unwrap(); assert!(summary.errors.is_empty()); // "kick" uppercased -> "KICK", truncated to 8 (already short enough) let exported = dest_dir.join("KICK.wav"); assert!(exported.exists(), "expected KICK.wav, got: {:?}", fs::read_dir(&dest_dir).unwrap().map(|e| e.unwrap().file_name()).collect::>()); } #[test] fn export_with_max_file_size_rejects_oversized() { let dir = tempfile::tempdir().unwrap(); let db = Database::open_in_memory().unwrap(); let store = SampleStore::new(dir.path().join("store")).unwrap(); let vfs_id = setup_vfs_with_samples(&db, &store, dir.path()); let items = collect_export_items(&db, vfs_id, None).unwrap(); let dest_dir = dir.path().join("export_size"); let config = ExportConfig { format: ExportFormat::Original, sample_rate: None, bit_depth: None, channels: ExportChannels::Original, naming_pattern: None, flatten: true, metadata_sidecar: false, destination: dest_dir.clone(), device_profile: None, naming_rules: None, max_file_size_bytes: Some(1), // 1 byte -- everything will exceed name_overrides: None, }; let summary = run_export(&items, &config, &store, |_, _, _| true).unwrap(); assert_eq!(summary.errors.len(), 1); assert!(summary.errors[0].1.contains("exceeds device file size limit")); // Verify the file was cleaned up let exported = dest_dir.join("kick.wav"); assert!(!exported.exists(), "oversized file should have been removed"); } #[test] fn export_naming_rules_dedup() { use crate::export::profile::{NamingCase, NamingRules}; let dir = tempfile::tempdir().unwrap(); let db = Database::open_in_memory().unwrap(); let store = SampleStore::new(dir.path().join("store")).unwrap(); let vfs_id = setup_vfs_with_samples(&db, &store, dir.path()); // Add a second sample that will collide after sanitization let wav_path = dir.path().join("KICK.wav"); write_test_wav(&wav_path, 1, 44100, &[0.1, -0.1]); let hash2 = store.import(&wav_path, &db).unwrap(); vfs::create_sample_link(&db, vfs_id, None, "KICK.wav", &hash2).unwrap(); let items = collect_export_items(&db, vfs_id, None).unwrap(); assert_eq!(items.len(), 2); let dest_dir = dir.path().join("export_dedup"); let config = ExportConfig { format: ExportFormat::Original, sample_rate: None, bit_depth: None, channels: ExportChannels::Original, naming_pattern: None, flatten: true, metadata_sidecar: false, destination: dest_dir.clone(), device_profile: None, naming_rules: Some(NamingRules { case: NamingCase::Upper, separator: '_', max_length: 64, strip_special: true, }), max_file_size_bytes: None, name_overrides: None, }; let summary = run_export(&items, &config, &store, |_, _, _| true).unwrap(); assert!(summary.errors.is_empty()); // Both sanitize to "KICK" -> second gets "_2" suffix let first = dest_dir.join("KICK.wav"); let second = dest_dir.join("KICK_2.wav"); assert!(first.exists(), "first file should be KICK.wav"); assert!(second.exists(), "deduped file should be KICK_2.wav"); } #[test] fn export_naming_rules_plus_pattern() { use crate::export::profile::{NamingCase, NamingRules}; let dir = tempfile::tempdir().unwrap(); let db = Database::open_in_memory().unwrap(); let store = SampleStore::new(dir.path().join("store")).unwrap(); let vfs_id = setup_vfs_with_samples(&db, &store, dir.path()); let items = collect_export_items(&db, vfs_id, None).unwrap(); let dest_dir = dir.path().join("export_pattern_rules"); // Pattern applies first (adds index prefix), then naming rules sanitize let config = ExportConfig { format: ExportFormat::Original, sample_rate: None, bit_depth: None, channels: ExportChannels::Original, naming_pattern: Some("{nn}_{name}".to_string()), flatten: true, metadata_sidecar: false, destination: dest_dir.clone(), device_profile: None, naming_rules: Some(NamingRules { case: NamingCase::Upper, separator: '_', max_length: 64, strip_special: true, }), max_file_size_bytes: None, name_overrides: None, }; let summary = run_export(&items, &config, &store, |_, _, _| true).unwrap(); assert!(summary.errors.is_empty()); // Pattern produces "01_kick", rules uppercase to "01_KICK" let exported = dest_dir.join("01_KICK.wav"); assert!(exported.exists(), "expected 01_KICK.wav, got: {:?}", fs::read_dir(&dest_dir).unwrap().map(|e| e.unwrap().file_name()).collect::>()); } // ── resolve_output_names() direct tests ────────────────────── fn make_item(name: &str, ext: &str) -> ExportItem { ExportItem { hash: crate::SampleHash::new("deadbeef"), ext: ext.to_string(), relative_path: PathBuf::from(name), name: name.to_string(), bpm: None, musical_key: None, classification: None, duration: None, tags: vec![], source_path: None, } } fn make_item_with_analysis(name: &str, ext: &str, bpm: f64, key: &str) -> ExportItem { ExportItem { hash: crate::SampleHash::new("deadbeef"), ext: ext.to_string(), relative_path: PathBuf::from(name), name: name.to_string(), bpm: Some(bpm), musical_key: Some(key.to_string()), classification: Some("drums".to_string()), duration: Some(1.5), tags: vec![], source_path: None, } } fn base_config(dest: PathBuf) -> ExportConfig { ExportConfig { format: ExportFormat::Original, sample_rate: None, bit_depth: None, channels: ExportChannels::Original, naming_pattern: None, flatten: true, metadata_sidecar: false, destination: dest, device_profile: None, naming_rules: None, max_file_size_bytes: None, name_overrides: None, } } #[test] fn resolve_no_pattern_keeps_original_names() { let items = vec![make_item("kick.wav", "wav"), make_item("snare.wav", "wav")]; let config = base_config(PathBuf::from("/tmp")); let names = resolve_output_names(&items, &config, None); assert_eq!(names, vec!["kick.wav", "snare.wav"]); } #[test] fn resolve_wav_format_changes_extension() { let items = vec![make_item("kick.mp3", "mp3"), make_item("snare.flac", "flac")]; let mut config = base_config(PathBuf::from("/tmp")); config.format = ExportFormat::Wav; let names = resolve_output_names(&items, &config, None); assert_eq!(names, vec!["kick.wav", "snare.wav"]); } #[test] fn resolve_aiff_format_changes_extension() { let items = vec![make_item("kick.wav", "wav")]; let mut config = base_config(PathBuf::from("/tmp")); config.format = ExportFormat::Aiff; let names = resolve_output_names(&items, &config, None); assert_eq!(names, vec!["kick.aiff"]); } #[test] fn resolve_with_pattern() { use crate::rename::RenamePattern; let items = vec![ make_item_with_analysis("kick.wav", "wav", 120.0, "C"), make_item_with_analysis("snare.wav", "wav", 120.0, "D"), ]; let config = base_config(PathBuf::from("/tmp")); let pat = RenamePattern::parse("{nn}_{name}").unwrap(); let names = resolve_output_names(&items, &config, Some(&pat)); assert_eq!(names, vec!["01_kick.wav", "02_snare.wav"]); } #[test] fn resolve_with_pattern_and_format_change() { use crate::rename::RenamePattern; let items = vec![make_item("kick.mp3", "mp3")]; let mut config = base_config(PathBuf::from("/tmp")); config.format = ExportFormat::Wav; let pat = RenamePattern::parse("{name}").unwrap(); let names = resolve_output_names(&items, &config, Some(&pat)); assert_eq!(names, vec!["kick.wav"]); } #[test] fn resolve_name_overrides_bypass_pattern() { use crate::rename::RenamePattern; let items = vec![make_item("kick.wav", "wav"), make_item("snare.wav", "wav")]; let mut config = base_config(PathBuf::from("/tmp")); config.name_overrides = Some(vec!["custom1.wav".to_string(), "custom2.wav".to_string()]); let pat = RenamePattern::parse("{nn}_{name}").unwrap(); // Overrides take precedence over pattern let names = resolve_output_names(&items, &config, Some(&pat)); assert_eq!(names, vec!["custom1.wav", "custom2.wav"]); } #[test] fn resolve_name_overrides_wrong_length_ignored() { let items = vec![make_item("kick.wav", "wav"), make_item("snare.wav", "wav")]; let mut config = base_config(PathBuf::from("/tmp")); // Wrong length (1 override for 2 items) — should fall through to normal resolution config.name_overrides = Some(vec!["only_one.wav".to_string()]); let names = resolve_output_names(&items, &config, None); assert_eq!(names, vec!["kick.wav", "snare.wav"]); } #[test] fn resolve_empty_items() { let items: Vec = vec![]; let config = base_config(PathBuf::from("/tmp")); let names = resolve_output_names(&items, &config, None); assert!(names.is_empty()); } #[test] fn resolve_dedup_after_sanitization() { use crate::export::profile::{NamingCase, NamingRules}; // Two items that sanitize to the same name let items = vec![make_item("Kick!.wav", "wav"), make_item("kick.wav", "wav")]; let mut config = base_config(PathBuf::from("/tmp")); config.naming_rules = Some(NamingRules { case: NamingCase::Upper, separator: '_', max_length: 64, strip_special: true, }); let names = resolve_output_names(&items, &config, None); assert_eq!(names, vec!["KICK.wav", "KICK_2.wav"]); } // ── run_export() additional coverage ───────────────────────── #[test] fn export_aiff_format() { let dir = tempfile::tempdir().unwrap(); let db = Database::open_in_memory().unwrap(); let store = SampleStore::new(dir.path().join("store")).unwrap(); let vfs_id = setup_vfs_with_samples(&db, &store, dir.path()); let items = collect_export_items(&db, vfs_id, None).unwrap(); let dest_dir = dir.path().join("export_aiff"); let config = ExportConfig { format: ExportFormat::Aiff, sample_rate: None, bit_depth: Some(16), channels: ExportChannels::Original, naming_pattern: None, flatten: true, metadata_sidecar: false, destination: dest_dir.clone(), device_profile: None, naming_rules: None, max_file_size_bytes: None, name_overrides: None, }; let summary = run_export(&items, &config, &store, |_, _, _| true).unwrap(); assert_eq!(summary.total, 1); assert!(summary.errors.is_empty()); let exported = dest_dir.join("kick.aiff"); assert!(exported.exists()); // Verify it's a valid AIFF (starts with FORM...AIFF) let bytes = fs::read(&exported).unwrap(); assert_eq!(&bytes[0..4], b"FORM"); assert_eq!(&bytes[8..12], b"AIFF"); } #[test] fn export_progress_callback_cancel() { let dir = tempfile::tempdir().unwrap(); let db = Database::open_in_memory().unwrap(); let store = SampleStore::new(dir.path().join("store")).unwrap(); let vfs_id = setup_vfs_with_samples(&db, &store, dir.path()); // Add a second sample let wav2 = dir.path().join("snare.wav"); write_test_wav(&wav2, 1, 44100, &[0.1, -0.1]); let hash2 = store.import(&wav2, &db).unwrap(); vfs::create_sample_link(&db, vfs_id, None, "snare.wav", &hash2).unwrap(); let items = collect_export_items(&db, vfs_id, None).unwrap(); assert!(items.len() >= 2); let dest_dir = dir.path().join("export_cancel"); let config = ExportConfig { format: ExportFormat::Original, sample_rate: None, bit_depth: None, channels: ExportChannels::Original, naming_pattern: None, flatten: true, metadata_sidecar: false, destination: dest_dir.clone(), device_profile: None, naming_rules: None, max_file_size_bytes: None, name_overrides: None, }; // Cancel after the first item let summary = run_export(&items, &config, &store, |completed, _, _| completed < 1).unwrap(); // Total reflects all items, but only 1 should have been processed assert_eq!(summary.total, items.len()); // At most 1 file should exist (the one processed before cancel) let exported_count = fs::read_dir(&dest_dir) .map(|d| d.count()) .unwrap_or(0); assert!(exported_count <= 1); } #[test] fn export_missing_source_records_error() { let dir = tempfile::tempdir().unwrap(); let store = SampleStore::new(dir.path().join("store")).unwrap(); let dest_dir = dir.path().join("export_missing"); // Create an item with a hash that doesn't exist in the store let items = vec![ExportItem { hash: crate::SampleHash::new("nonexistent_hash_value"), ext: "wav".to_string(), relative_path: PathBuf::from("missing.wav"), name: "missing.wav".to_string(), bpm: None, musical_key: None, classification: None, duration: None, tags: vec![], source_path: None, }]; let config = ExportConfig { format: ExportFormat::Original, sample_rate: None, bit_depth: None, channels: ExportChannels::Original, naming_pattern: None, flatten: true, metadata_sidecar: false, destination: dest_dir, device_profile: None, naming_rules: None, max_file_size_bytes: None, name_overrides: None, }; let summary = run_export(&items, &config, &store, |_, _, _| true).unwrap(); assert_eq!(summary.total, 1); assert_eq!(summary.errors.len(), 1); assert_eq!(summary.errors[0].0, "missing.wav"); } #[test] fn export_sidecar_contains_all_fields() { let dir = tempfile::tempdir().unwrap(); let db = Database::open_in_memory().unwrap(); let store = SampleStore::new(dir.path().join("store")).unwrap(); let vfs_id = setup_vfs_with_samples(&db, &store, dir.path()); let mut items = collect_export_items(&db, vfs_id, None).unwrap(); // Add analysis metadata to the item items[0].bpm = Some(120.0); items[0].musical_key = Some("Am".to_string()); items[0].classification = Some("drums".to_string()); items[0].duration = Some(0.5); items[0].tags = vec!["one-shot".to_string(), "kick".to_string()]; let dest_dir = dir.path().join("export_sidecar_fields"); let config = ExportConfig { format: ExportFormat::Original, sample_rate: None, bit_depth: None, channels: ExportChannels::Original, naming_pattern: None, flatten: true, metadata_sidecar: true, destination: dest_dir.clone(), device_profile: None, naming_rules: None, max_file_size_bytes: None, name_overrides: None, }; let summary = run_export(&items, &config, &store, |_, _, _| true).unwrap(); assert!(summary.errors.is_empty()); let sidecar_path = dest_dir.join("kick.wav.audiofiles.json"); assert!(sidecar_path.exists()); let contents = fs::read_to_string(&sidecar_path).unwrap(); let json: serde_json::Value = serde_json::from_str(&contents).unwrap(); assert_eq!(json["bpm"], 120.0); assert_eq!(json["key"], "Am"); assert_eq!(json["classification"], "drums"); assert_eq!(json["duration"], 0.5); assert_eq!(json["tags"], serde_json::json!(["one-shot", "kick"])); assert!(json["hash"].is_string()); assert_eq!(json["name"], "kick.wav"); } #[test] fn export_preserves_directory_structure() { let dir = tempfile::tempdir().unwrap(); let db = Database::open_in_memory().unwrap(); let store = SampleStore::new(dir.path().join("store")).unwrap(); let vfs_id = setup_vfs_with_samples(&db, &store, dir.path()); let items = collect_export_items(&db, vfs_id, None).unwrap(); // Verify relative_path includes directory assert_eq!(items[0].relative_path, PathBuf::from("Drums/kick.wav")); let dest_dir = dir.path().join("export_dirs"); let config = ExportConfig { format: ExportFormat::Original, sample_rate: None, bit_depth: None, channels: ExportChannels::Original, naming_pattern: None, flatten: false, // preserve structure metadata_sidecar: false, destination: dest_dir.clone(), device_profile: None, naming_rules: None, max_file_size_bytes: None, name_overrides: None, }; let summary = run_export(&items, &config, &store, |_, _, _| true).unwrap(); assert!(summary.errors.is_empty()); // Should be in Drums/ subdirectory let exported = dest_dir.join("Drums").join("kick.wav"); assert!(exported.exists()); // Flat version should NOT exist assert!(!dest_dir.join("kick.wav").exists()); } }