use std::collections::HashMap; use std::path::Path; use crate::NominaError; use crate::preset::{NominaPreset, PresetRule}; /// Parse a Bulk Rename Utility (.bru) preset file into a NominaPreset. /// BRU files use an INI-like format with sections for each operation. pub fn parse_bru_file(path: &Path) -> crate::Result { let data = std::fs::read_to_string(path).map_err(|e| NominaError::Filesystem { path: path.to_path_buf(), source: e, })?; let sections = parse_ini(&data); let mut rules = Vec::new(); // RegEx(1) section if let Some(sec) = sections.get("regexmatch(1)") { let pattern = sec.get("match").cloned().unwrap_or_default(); let replace = sec.get("replace").cloned().unwrap_or_default(); let case = sec.get("casesensitive").map(|v| v == "1").unwrap_or(false); if !pattern.is_empty() { rules.push(make_rule("regex", serde_json::json!({ "pattern": pattern, "replacement": replace, "case_sensitive": case, "global": true, "target": "Name", }))); } } // Replace sections - BRU supports multiple replace slots for i in 1..=14 { let key = if i == 1 { "replace(1)".to_string() } else { format!("replace({})", i) }; if let Some(sec) = sections.get(&key) { let search = sec.get("find").or(sec.get("search")).cloned().unwrap_or_default(); let replace = sec.get("replace").cloned().unwrap_or_default(); let case = sec.get("casesensitive").or(sec.get("matchcase")).map(|v| v == "1").unwrap_or(false); if !search.is_empty() { rules.push(make_rule("replace", serde_json::json!({ "search": search, "replace_with": replace, "match_case": case, "use_regex": false, "target": "Name", }))); } } } // Remove section if let Some(sec) = sections.get("remove(1)") { let first_n: usize = sec.get("first").and_then(|v| v.parse().ok()).unwrap_or(0); let last_n: usize = sec.get("last").and_then(|v| v.parse().ok()).unwrap_or(0); let from: usize = sec.get("from").and_then(|v| v.parse().ok()).unwrap_or(0); let to: usize = sec.get("to").and_then(|v| v.parse().ok()).unwrap_or(0); if first_n > 0 || last_n > 0 || from != to { rules.push(make_rule("remove", serde_json::json!({ "first_n": first_n, "last_n": last_n, "from": from, "to": to, "crop_before": "", "crop_after": "", "remove_pattern": "", "collapse_chars": false, "trim": { "spaces": false, "dots": false, "dashes": false, "underscores": false }, }))); } } // Add / Prefix / Suffix section if let Some(sec) = sections.get("add(1)") { let prefix = sec.get("prefix").cloned().unwrap_or_default(); let suffix = sec.get("suffix").cloned().unwrap_or_default(); let insert = sec.get("insert").cloned().unwrap_or_default(); let at_pos: usize = sec.get("atpos").or(sec.get("at")).and_then(|v| v.parse().ok()).unwrap_or(0); if !prefix.is_empty() || !suffix.is_empty() || !insert.is_empty() { rules.push(make_rule("add", serde_json::json!({ "prefix": prefix, "suffix": suffix, "insert": insert, "position": at_pos, "inserts": [], }))); } } // Case section if let Some(sec) = sections.get("case(1)") { let mode_num: u32 = sec.get("type").or(sec.get("mode")).and_then(|v| v.parse().ok()).unwrap_or(0); let mode = match mode_num { 1 => "Lower", 2 => "Upper", 3 => "Title", 4 => "Sentence", 5 => "Invert", _ => "Same", }; if mode != "Same" { rules.push(make_rule("case", serde_json::json!({ "mode": mode, "target": "Name", }))); } } // Numbering section if let Some(sec) = sections.get("number(1)") { let mode_num: u32 = sec.get("mode").and_then(|v| v.parse().ok()).unwrap_or(0); if mode_num > 0 { let start: i64 = sec.get("start").and_then(|v| v.parse().ok()).unwrap_or(1); let step: i64 = sec.get("incr").or(sec.get("step")).and_then(|v| v.parse().ok()).unwrap_or(1); let pad: usize = sec.get("pad").and_then(|v| v.parse().ok()).unwrap_or(0); let mode = match mode_num { 1 => "Prefix", 2 => "Suffix", 3 => "Both", _ => "Prefix", }; rules.push(make_rule("numbering", serde_json::json!({ "mode": mode, "start": start, "step": step, "padding": pad, "separator": "", "format": "Decimal", }))); } } // Extension section if let Some(sec) = sections.get("extension(1)") { let mode_num: u32 = sec.get("type").or(sec.get("mode")).and_then(|v| v.parse().ok()).unwrap_or(0); let new_ext = sec.get("new").or(sec.get("extension")).cloned().unwrap_or_default(); let mode = match mode_num { 1 => "Lower", 2 => "Upper", 3 => "Title", 4 if !new_ext.is_empty() => "Fixed", 5 => "Remove", _ => "Same", }; if mode != "Same" { let mut json = serde_json::json!({ "mode": mode, "new_extension": new_ext, "mapping": [], }); if mode == "Fixed" { json["new_extension"] = serde_json::Value::String(new_ext); } rules.push(make_rule("extension", json)); } } let name = path.file_stem() .map(|s| s.to_string_lossy().to_string()) .unwrap_or_else(|| "Imported BRU preset".to_string()); Ok(NominaPreset { version: 1, name, description: "Imported from Bulk Rename Utility".to_string(), created: chrono::Utc::now().to_rfc3339(), rules, filters: None, }) } fn make_rule(rule_type: &str, config: serde_json::Value) -> PresetRule { let mut map = config.as_object().cloned().unwrap_or_default(); map.insert("type".to_string(), serde_json::Value::String(rule_type.to_string())); map.insert("step_mode".to_string(), serde_json::Value::String("Simultaneous".to_string())); map.insert("enabled".to_string(), serde_json::Value::Bool(true)); serde_json::from_value(serde_json::Value::Object(map)).unwrap() } /// Parse INI-like BRU format into section -> key/value maps. /// BRU uses `[SectionName]` headers and `Key=Value` pairs. /// Section names are normalized to lowercase for easier lookup. fn parse_ini(text: &str) -> HashMap> { let mut sections: HashMap> = HashMap::new(); let mut current_section = String::new(); for line in text.lines() { let trimmed = line.trim(); if trimmed.is_empty() || trimmed.starts_with(';') || trimmed.starts_with('#') { continue; } if trimmed.starts_with('[') && trimmed.ends_with(']') { current_section = trimmed[1..trimmed.len() - 1].trim().to_lowercase(); sections.entry(current_section.clone()).or_default(); } else if let Some((key, value)) = trimmed.split_once('=') { if !current_section.is_empty() { sections .entry(current_section.clone()) .or_default() .insert(key.trim().to_lowercase(), value.trim().to_string()); } } } sections }