//! Output filename resolution: rename patterns, sanitization, deduplication. use crate::rename::{RenameContext, RenamePattern}; use crate::util::split_name_ext; use super::sanitize; use super::{ExportConfig, ExportFormat, ExportItem}; use tracing::instrument; /// Resolve output filenames for all items, applying rename pattern, sanitization, and deduplication. /// /// If `config.name_overrides` is set, those names are returned directly (used by the backend /// to inject hook-transformed names). Otherwise, names are computed from the rename pattern, /// original item names, naming rules, and deduplication. #[instrument(skip_all)] pub fn resolve_output_names( items: &[ExportItem], config: &ExportConfig, pattern: Option<&RenamePattern>, ) -> Vec { // If the backend pre-computed names (e.g. via transform_filename hook), use them directly. if let Some(ref overrides) = config.name_overrides && overrides.len() == items.len() { return overrides.clone(); } let output_ext = match config.format { ExportFormat::Wav => "wav", ExportFormat::Aiff => "aiff", ExportFormat::Original => "", }; let mut names: Vec = if let Some(pat) = pattern { let contexts: Vec = items .iter() .enumerate() .map(|(i, item)| { let (stem, ext) = split_name_ext(&item.name); RenameContext { name: stem, extension: if output_ext.is_empty() { ext } else { output_ext.to_string() }, bpm: item.bpm, musical_key: item.musical_key.clone(), classification: item.classification.clone(), duration: item.duration, index: i, } }) .collect(); let stems = pat.resolve_all(&contexts); stems .into_iter() .zip(items.iter()) .map(|(stem, item)| { let ext = if !output_ext.is_empty() { output_ext } else { &item.ext }; let stem = if stem.is_empty() { item.name.split('.').next().unwrap_or("untitled").to_string() } else { stem }; format!("{stem}.{ext}") }) .collect() } else { // Use original names, changing extension if converting items .iter() .map(|item| { if !output_ext.is_empty() { let (stem, _) = split_name_ext(&item.name); format!("{stem}.{output_ext}") } else { item.name.clone() } }) .collect() }; // Apply naming rules (sanitize stems) if set if let Some(ref rules) = config.naming_rules { for name in &mut names { let (stem, ext) = split_name_ext(name); let sanitized = sanitize::sanitize_filename(&stem, rules); let sanitized = if sanitized.is_empty() { "untitled".to_string() } else { sanitized }; *name = format!("{sanitized}.{ext}"); } } // Strip path separators and NUL bytes from all filenames to prevent path traversal, // even when no NamingRules are configured. for name in &mut names { *name = name.replace(['/', '\\', '\0'], "_"); // Reject . and .. as bare stems (after extension split these would be just dots) let stem_part = name.split('.').next().unwrap_or(""); if stem_part == ".." { *name = name.replacen("..", "_", 1); } } // Deduplicate: same-named files (from different VFS dirs, or after sanitization) // would silently overwrite each other in flat export. Append _2, _3, etc. let mut seen = std::collections::HashMap::::new(); for name in &mut names { let lower = name.to_lowercase(); let count = seen.entry(lower).or_insert(0); *count += 1; if *count > 1 { let (stem, ext) = split_name_ext(name); *name = format!("{stem}_{}.{ext}", count); } } names }