app_lib/
xref.rs

1//! Cross-reference engine: extract plugin references from DAW project files.
2//!
3//! Parses 11 DAW formats: Ableton (.als), REAPER (.rpp), Bitwig (.bwproject),
4//! Studio One (.song), DAWproject, FL Studio (.flp), Logic Pro (.logicx),
5//! Cubase/Nuendo (.cpr), Pro Tools (.ptx/.ptf), and Reason (.reason).
6//! Returns deduplicated lists of plugin names, manufacturers, and types.
7
8use flate2::read::GzDecoder;
9use regex::Regex;
10use serde::{Deserialize, Serialize};
11use std::collections::HashSet;
12use std::fs;
13use std::io::Read;
14use std::path::Path;
15use std::sync::LazyLock;
16
17/// Extensions implemented in [`extract_plugins`] (plugin cross-reference).
18pub(crate) const XREF_SUPPORTED_EXTENSIONS: &[&str] = &[
19    ".als", ".rpp", ".rpp-bak", ".bwproject", ".song", ".dawproject", ".flp", ".logicx", ".cpr",
20    ".npr", ".ptx", ".ptf", ".reason",
21];
22
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
24pub struct PluginRef {
25    pub name: String,
26    #[serde(rename = "normalizedName")]
27    pub normalized_name: String,
28    pub manufacturer: String,
29    #[serde(rename = "pluginType")]
30    pub plugin_type: String, // "VST2", "VST3", "AU"
31}
32
33/// Regex to strip architecture/platform suffixes from plugin names.
34static ARCH_SUFFIX_RE: LazyLock<Regex> = LazyLock::new(|| {
35    Regex::new(r"(?i)\s*[\(\[](x64|x86_64|x86|arm64|aarch64|64[- ]?bit|32[- ]?bit|intel|apple silicon|universal|stereo|mono|vst3?|au|aax)[\)\]]$").unwrap()
36});
37
38// ── Ableton .als regexes (compiled once) ──
39
40static ALS_VST2_BLOCK_RE: LazyLock<Regex> =
41    LazyLock::new(|| Regex::new(r#"<VstPluginInfo[^>]*>[\s\S]*?</VstPluginInfo>"#).unwrap());
42static ALS_VST2_NAME_RE: LazyLock<Regex> =
43    LazyLock::new(|| Regex::new(r#"<PlugName\s+Value="([^"]+)""#).unwrap());
44static ALS_VST2_MFG_RE: LazyLock<Regex> =
45    LazyLock::new(|| Regex::new(r#"<Manufacturer\s+Value="([^"]+)""#).unwrap());
46static ALS_VST3_BLOCK_RE: LazyLock<Regex> =
47    LazyLock::new(|| Regex::new(r#"<Vst3PluginInfo[^>]*>[\s\S]*?</Vst3PluginInfo>"#).unwrap());
48static ALS_VST3_NAME_RE: LazyLock<Regex> =
49    LazyLock::new(|| Regex::new(r#"<Name\s+Value="([^"]+)""#).unwrap());
50static ALS_VST3_MFG_RE: LazyLock<Regex> =
51    LazyLock::new(|| Regex::new(r#"<DeviceCreator\s+Value="([^"]+)""#).unwrap());
52static ALS_AU_BLOCK_RE: LazyLock<Regex> =
53    LazyLock::new(|| Regex::new(r#"<AuPluginInfo[^>]*>[\s\S]*?</AuPluginInfo>"#).unwrap());
54static ALS_AU_NAME_RE: LazyLock<Regex> =
55    LazyLock::new(|| Regex::new(r#"<Name\s+Value="([^"]+)""#).unwrap());
56static ALS_AU_MFG_RE: LazyLock<Regex> =
57    LazyLock::new(|| Regex::new(r#"<Manufacturer\s+Value="([^"]+)""#).unwrap());
58
59// ── REAPER .rpp regex (compiled once) ──
60
61static RPP_PLUGIN_RE: LazyLock<Regex> = LazyLock::new(|| {
62    Regex::new(r#"<(?:VST|AU|CLAP)\s+"(VST3?|AU|CLAP):\s*(.+?)\s*(?:\(([^)]+)\))?\s*""#).unwrap()
63});
64
65// ── Studio One (plugName only) / DAWproject XML regexes (compiled once) ──
66
67static XML_PLUG_NAME_RE: LazyLock<Regex> =
68    LazyLock::new(|| Regex::new(r#"plugName="([^"]+)""#).unwrap());
69static XML_DEVICE_NAME_RE: LazyLock<Regex> =
70    LazyLock::new(|| Regex::new(r#"deviceName="([^"]+)""#).unwrap());
71static XML_PLUGIN_NAME_RE: LazyLock<Regex> =
72    LazyLock::new(|| Regex::new(r#"<Plugin\s+name="([^"]+)""#).unwrap());
73
74/// Normalize a plugin name for matching: lowercase, strip arch suffixes,
75/// collapse whitespace, trim.
76pub fn normalize_plugin_name(name: &str) -> String {
77    let mut s = name.trim().to_string();
78    // Strip trailing arch/platform suffixes repeatedly (e.g. "Serum (x64) (VST3)")
79    loop {
80        let before = s.len();
81        s = ARCH_SUFFIX_RE.replace(&s, "").to_string();
82        if s.len() == before {
83            break;
84        }
85    }
86    // Strip standalone trailing " x64", " x86" etc. without parens
87    static BARE_SUFFIX_RE: LazyLock<Regex> =
88        LazyLock::new(|| Regex::new(r"(?i)\s+(x64|x86_64|x86|64bit|32bit)$").unwrap());
89    s = BARE_SUFFIX_RE.replace(&s, "").to_string();
90    // Collapse internal whitespace and lowercase
91    let result = s
92        .split_whitespace()
93        .collect::<Vec<_>>()
94        .join(" ")
95        .to_lowercase();
96    // If stripping removed everything, fall back to original lowercased name
97    if result.is_empty() {
98        name.trim().to_lowercase()
99    } else {
100        result
101    }
102}
103
104/// Extract plugin references from a DAW project file.
105/// Returns an empty vec for unsupported formats.
106pub fn extract_plugins(project_path: &str) -> Vec<PluginRef> {
107    let path = Path::new(project_path);
108    let ext = path
109        .extension()
110        .or_else(|| {
111            // Handle compound extensions like .rpp-bak
112            let name = path.file_name()?.to_str()?;
113            if name.ends_with(".rpp-bak") {
114                Some(std::ffi::OsStr::new("rpp-bak"))
115            } else {
116                None
117            }
118        })
119        .and_then(|e| e.to_str())
120        .unwrap_or("")
121        .to_lowercase();
122
123    let mut plugins = match ext.as_str() {
124        "als" => parse_ableton(path),
125        "rpp" | "rpp-bak" => parse_reaper(path),
126        "bwproject" => parse_bitwig(path),
127        "song" => parse_studio_one(path),
128        "dawproject" => parse_dawproject(path),
129        "flp" => parse_flp(path),
130        "logicx" => parse_logic(path),
131        "cpr" | "npr" => parse_cubase(path),
132        "ptx" | "ptf" => parse_protools(path),
133        "reason" => parse_reason(path),
134        _ => vec![],
135    };
136
137    // Deduplicate by (normalized_name, plugin_type)
138    let mut seen = HashSet::new();
139    plugins.retain(|p| seen.insert((p.normalized_name.clone(), p.plugin_type.clone())));
140    plugins.sort_by(|a, b| a.normalized_name.cmp(&b.normalized_name));
141    plugins
142}
143
144/// Parse Ableton Live .als file (gzip-compressed XML).
145///
146/// Looks for:
147/// - `<VstPluginInfo>` blocks with `<PlugName Value="..."/>` and `<Manufacturer Value="..."/>`
148/// - `<Vst3PluginInfo>` blocks with `<Name Value="..."/>` and `<DeviceCreator Value="..."/>`
149/// - `<AuPluginInfo>` blocks with `<Name Value="..."/>` and `<Manufacturer Value="..."/>`
150fn parse_ableton(path: &Path) -> Vec<PluginRef> {
151    let data = match fs::read(path) {
152        Ok(d) => d,
153        Err(_) => return vec![],
154    };
155
156    let mut decoder = GzDecoder::new(&data[..]);
157    let mut xml = String::new();
158    if decoder.read_to_string(&mut xml).is_err() {
159        return vec![];
160    }
161
162    let mut plugins = Vec::new();
163
164    // VST2 plugins: <VstPluginInfo> ... <PlugName Value="X"/> ... <Manufacturer Value="Y"/>
165    for block in ALS_VST2_BLOCK_RE.find_iter(&xml) {
166        let text = block.as_str();
167        let name = ALS_VST2_NAME_RE
168            .captures(text)
169            .and_then(|c| c.get(1))
170            .map(|m| m.as_str().to_string())
171            .unwrap_or_default();
172        let mfg = ALS_VST2_MFG_RE
173            .captures(text)
174            .and_then(|c| c.get(1))
175            .map(|m| m.as_str().to_string())
176            .unwrap_or_default();
177        if !name.is_empty() {
178            let normalized_name = normalize_plugin_name(&name);
179            plugins.push(PluginRef {
180                name,
181                normalized_name,
182                manufacturer: mfg,
183                plugin_type: "VST2".into(),
184            });
185        }
186    }
187
188    // VST3 plugins: <Vst3PluginInfo> ... <Name Value="X"/> ... <DeviceCreator Value="Y"/>
189    for block in ALS_VST3_BLOCK_RE.find_iter(&xml) {
190        let text = block.as_str();
191        let name = ALS_VST3_NAME_RE
192            .captures(text)
193            .and_then(|c| c.get(1))
194            .map(|m| m.as_str().to_string())
195            .unwrap_or_default();
196        let mfg = ALS_VST3_MFG_RE
197            .captures(text)
198            .and_then(|c| c.get(1))
199            .map(|m| m.as_str().to_string())
200            .unwrap_or_default();
201        if !name.is_empty() {
202            let normalized_name = normalize_plugin_name(&name);
203            plugins.push(PluginRef {
204                name,
205                normalized_name,
206                manufacturer: mfg,
207                plugin_type: "VST3".into(),
208            });
209        }
210    }
211
212    // AU plugins: <AuPluginInfo> ... <Name Value="X"/> ... <Manufacturer Value="Y"/>
213    for block in ALS_AU_BLOCK_RE.find_iter(&xml) {
214        let text = block.as_str();
215        let name = ALS_AU_NAME_RE
216            .captures(text)
217            .and_then(|c| c.get(1))
218            .map(|m| m.as_str().to_string())
219            .unwrap_or_default();
220        let mfg = ALS_AU_MFG_RE
221            .captures(text)
222            .and_then(|c| c.get(1))
223            .map(|m| m.as_str().to_string())
224            .unwrap_or_default();
225        if !name.is_empty() {
226            let normalized_name = normalize_plugin_name(&name);
227            plugins.push(PluginRef {
228                name,
229                normalized_name,
230                manufacturer: mfg,
231                plugin_type: "AU".into(),
232            });
233        }
234    }
235
236    plugins
237}
238
239/// Parse REAPER .rpp file (plaintext).
240///
241/// Looks for lines like:
242/// - `<VST "VST: Plugin Name (Manufacturer)" file.dll ...`
243/// - `<VST "VST3: Plugin Name (Manufacturer)" file.vst3 ...`
244/// - `<AU "AU: Plugin Name (Manufacturer)" ...`
245/// - `<CLAP "CLAP: Plugin Name (Manufacturer)" ...`
246fn parse_reaper(path: &Path) -> Vec<PluginRef> {
247    let text = match fs::read_to_string(path) {
248        Ok(t) => t,
249        Err(_) => return vec![],
250    };
251
252    let mut plugins = Vec::new();
253
254    // Match <VST "VST: Name (Mfg)" or <VST "VST3: Name (Mfg)" or <AU "AU: Name (Mfg)"
255    for cap in RPP_PLUGIN_RE.captures_iter(&text) {
256        let ptype = cap.get(1).map(|m| m.as_str()).unwrap_or("VST2");
257        let name = cap
258            .get(2)
259            .map(|m| m.as_str().trim().to_string())
260            .unwrap_or_default();
261        let mfg = cap
262            .get(3)
263            .map(|m| m.as_str().trim().to_string())
264            .unwrap_or_default();
265
266        if !name.is_empty() {
267            let plugin_type = match ptype {
268                "VST" => "VST2",
269                "VST3" => "VST3",
270                "AU" => "AU",
271                "CLAP" => "CLAP",
272                _ => "VST2",
273            }
274            .to_string();
275
276            let normalized_name = normalize_plugin_name(&name);
277            plugins.push(PluginRef {
278                name,
279                normalized_name,
280                manufacturer: mfg,
281                plugin_type,
282            });
283        }
284    }
285
286    plugins
287}
288
289/// Parse Bitwig Studio .bwproject file (binary with embedded strings).
290///
291/// Bitwig files have a `BtWg` magic header followed by binary-serialized
292/// project data. Plugin references are stored as DLL/VST3/component paths
293/// in plain text within the binary. We extract them via string scanning.
294/// Parse Studio One .song file (ZIP containing song.xml + Devices/*.xml).
295fn parse_studio_one(path: &Path) -> Vec<PluginRef> {
296    let file = match fs::File::open(path) {
297        Ok(f) => f,
298        Err(_) => return vec![],
299    };
300    let mut archive = match zip::ZipArchive::new(file) {
301        Ok(a) => a,
302        Err(_) => return vec![],
303    };
304    let mut all_xml = String::new();
305    // Read all XML files in the archive
306    let names: Vec<String> = (0..archive.len())
307        .filter_map(|i| archive.by_index(i).ok().map(|e| e.name().to_string()))
308        .filter(|n| n.ends_with(".xml"))
309        .collect();
310    for name in &names {
311        if let Ok(mut entry) = archive.by_name(name) {
312            let mut s = String::new();
313            if entry.read_to_string(&mut s).is_ok() {
314                all_xml.push_str(&s);
315                all_xml.push('\n');
316            }
317        }
318    }
319    if all_xml.is_empty() {
320        return vec![];
321    }
322    // Only `plugName=` denotes a plugin instance. Studio One XML also has many
323    // `label="..."` (tracks, buses, UI) and `deviceName="..."` is often the
324    // vendor/model alongside `plugName` — matching those inflated xref counts badly.
325    extract_plugins_from_xml(&all_xml, &[(&XML_PLUG_NAME_RE, "", "VST")])
326}
327
328/// Parse .dawproject file (ZIP containing project.xml — open standard).
329fn parse_dawproject(path: &Path) -> Vec<PluginRef> {
330    let file = match fs::File::open(path) {
331        Ok(f) => f,
332        Err(_) => return vec![],
333    };
334    let mut archive = match zip::ZipArchive::new(file) {
335        Ok(a) => a,
336        Err(_) => return vec![],
337    };
338    let xml = match archive.by_name("project.xml") {
339        Ok(mut entry) => {
340            let mut s = String::new();
341            entry.read_to_string(&mut s).ok();
342            s
343        }
344        Err(_) => return vec![],
345    };
346    extract_plugins_from_xml(
347        &xml,
348        &[
349            (&XML_PLUGIN_NAME_RE, "", "VST"),
350            (&XML_DEVICE_NAME_RE, "", "VST"),
351        ],
352    )
353}
354
355/// Parse FL Studio .flp file (binary chunk format).
356/// Uses binary string extraction + UTF-16LE scanning for plugin paths.
357fn parse_flp(path: &Path) -> Vec<PluginRef> {
358    let data = match fs::read(path) {
359        Ok(d) => d,
360        Err(_) => return vec![],
361    };
362    let mut plugins = extract_plugins_from_binary(&data);
363    // FL Studio stores many strings as UTF-16LE — scan for plugin paths in UTF-16
364    plugins.extend(extract_plugins_utf16le(&data));
365    plugins
366}
367
368/// Extract plugin references from UTF-16LE encoded strings in binary data.
369/// FL Studio and some other DAWs use UTF-16LE for internal strings.
370fn extract_plugins_utf16le(data: &[u8]) -> Vec<PluginRef> {
371    let mut plugins = Vec::new();
372    if data.len() < 2 {
373        return plugins;
374    }
375    // Scan for runs of valid UTF-16LE characters
376    let mut start = 0;
377    while start + 1 < data.len() {
378        let lo = data[start];
379        let hi = data[start + 1];
380        // Check if this looks like a printable ASCII char in UTF-16LE (lo=printable, hi=0)
381        if hi == 0 && (0x20..=0x7E).contains(&lo) {
382            let run_start = start;
383            let mut end = start;
384            while end + 1 < data.len() && data[end + 1] == 0 && (0x20..=0x7E).contains(&data[end]) {
385                end += 2;
386            }
387            let char_count = (end - run_start) / 2;
388            if char_count >= 6 {
389                let u16s: Vec<u16> = data[run_start..end]
390                    .chunks(2)
391                    .map(|c| u16::from_le_bytes([c[0], c.get(1).copied().unwrap_or(0)]))
392                    .collect();
393                let s = String::from_utf16_lossy(&u16s);
394                if let Some(p) = extract_plugin_from_string(&s) {
395                    plugins.push(p);
396                }
397            }
398            start = end;
399        } else {
400            start += 1;
401        }
402    }
403    plugins
404}
405
406/// Parse Logic Pro .logicx package (contains binary plists with plugin info).
407fn parse_logic(path: &Path) -> Vec<PluginRef> {
408    let candidates = [
409        path.join("Alternatives/000/ProjectData"),
410        path.join("ProjectData"),
411    ];
412    let mut all_plugins = Vec::new();
413
414    for plist_path in &candidates {
415        if let Ok(data) = fs::read(plist_path) {
416            // Try plist parsing
417            if let Ok(val) = plist::from_bytes::<plist::Value>(&data) {
418                extract_plugins_from_plist(&val, &mut all_plugins);
419            }
420            // Binary string extraction for file paths (.component, .vst3, etc.)
421            all_plugins.extend(extract_plugins_from_binary(&data));
422            // Extract AU identifiers
423            all_plugins.extend(extract_au_identifiers(&data));
424            // Extract known Logic plugin names by scanning for standalone strings
425            all_plugins.extend(extract_logic_plugin_names(&data));
426        }
427    }
428
429    if all_plugins.is_empty() {
430        all_plugins = extract_plugins_from_dir(path);
431    }
432
433    all_plugins
434}
435
436/// Extract Logic Pro plugin names from binary data.
437/// Logic stores plugin names as standalone readable strings in the ProjectData binary.
438fn extract_logic_plugin_names(data: &[u8]) -> Vec<PluginRef> {
439    let mut plugins = Vec::new();
440    // Known third-party plugins and Logic stock effects to look for
441    let stock_effects = [
442        "Channel EQ",
443        "Compressor",
444        "Adaptive Limiter",
445        "Multipressor",
446        "Space Designer",
447        "Tape Delay",
448        "Stereo Delay",
449        "ChromaVerb",
450        "Exciter",
451        "Overdrive",
452        "AutoFilter",
453        "Direction Mixer",
454        "Gain",
455        "Stereo Spread",
456        "Limiter",
457        "Noise Gate",
458        "DeEsser",
459        "Tremolo",
460        "Phaser",
461        "Flanger",
462        "Chorus",
463        "Ringshifter",
464        "Pitch Correction",
465        "Pitch Shifter",
466        "Vocal Transformer",
467    ];
468    // Extract all readable strings and check for known plugin names
469    let mut current = Vec::new();
470    let mut found_names = std::collections::HashSet::new();
471    for &byte in data {
472        if (0x20..=0x7E).contains(&byte) {
473            current.push(byte);
474        } else {
475            if current.len() >= 3 && current.len() <= 64 {
476                let s = String::from_utf8_lossy(&current).to_string();
477                // Skip common non-plugin strings
478                if !s.contains('/')
479                    && !s.contains('\\')
480                    && !s.starts_with("com.")
481                    && !s.starts_with("kD")
482                    && !s.starts_with("0x")
483                    && !s.starts_with("Aco")
484                    && !s.starts_with("Output ")
485                    && !s.starts_with("Input ")
486                    && !s.starts_with("Automatic-")
487                    && !s.contains("KeyLab")
488                    && !s.ends_with(".pst")
489                    && !s.ends_with(".aif")
490                    && !s.ends_with(".wav")
491                    && !s.ends_with(".cst")
492                    && !s.ends_with(".exs")
493                    && !found_names.contains(&s)
494                {
495                    let is_stock = stock_effects.contains(&s.as_str());
496                    let known_third_party = [
497                        "Sylenth1",
498                        "Spire",
499                        "Serum",
500                        "Massive",
501                        "Kontakt",
502                        "Omnisphere",
503                        "Nexus",
504                        "Diva",
505                        "Hive",
506                        "Vital",
507                        "Phase Plant",
508                        "Pro-Q",
509                        "Pro-L",
510                        "Pro-R",
511                        "Pro-C",
512                        "Pro-G",
513                        "Pro-MB",
514                        "Ozone",
515                        "Neutron",
516                        "Trash",
517                        "VocalSynth",
518                        "Iris",
519                        "Valhalla",
520                        "FabFilter",
521                        "iZotope",
522                        "Waves",
523                        "Soundtoys",
524                        "LFOTool",
525                        "CamelCrusher",
526                        "OTT",
527                        "Sausage Fattener",
528                        "Saturn",
529                        "Volcano",
530                        "Timeless",
531                        "Decapitator",
532                        "EchoBoy",
533                        "Radiator",
534                        "Devil-Loc",
535                        "PanMan",
536                        "FilterFreak",
537                        "PhaseMistress",
538                        "RC-20",
539                        "Kickstart",
540                        "Cableguys",
541                        "Portal",
542                        "Output",
543                        "Arturia",
544                        "u-he",
545                        "Xfer",
546                        "Native Instruments",
547                        "Spectrasonics",
548                        "Alchemy",
549                        "ES2",
550                        "EXS24",
551                        "Retro Synth",
552                        "Drum Kit Designer",
553                    ];
554                    let is_known = known_third_party
555                        .iter()
556                        .any(|&kp| s.starts_with(kp) || s == kp);
557
558                    if is_stock || is_known {
559                        // Trim trailing non-alphanumeric junk (binary artifacts)
560                        let s = s
561                            .trim_end_matches(|c: char| {
562                                !c.is_alphanumeric() && c != ')' && c != ']'
563                            })
564                            .to_string();
565                        if s.len() < 2 {
566                            current.clear();
567                            continue;
568                        }
569                        found_names.insert(s.clone());
570                        let normalized = normalize_plugin_name(&s);
571                        if !normalized.is_empty() {
572                            plugins.push(PluginRef {
573                                name: s,
574                                normalized_name: normalized,
575                                manufacturer: String::new(),
576                                plugin_type: if is_stock {
577                                    "AU (Stock)".into()
578                                } else {
579                                    "AU".into()
580                                },
581                            });
582                        }
583                    }
584                }
585            }
586            current.clear();
587        }
588    }
589    plugins
590}
591
592/// Extract Audio Unit identifiers from binary data.
593/// Logic stores AU plugins as 4-char codes like "aufx", "aumu", "aumf" followed by subtype and manufacturer.
594fn extract_au_identifiers(data: &[u8]) -> Vec<PluginRef> {
595    let mut plugins = Vec::new();
596    let mut current = Vec::new();
597    // Look for readable strings that could be AU plugin names
598    for &byte in data {
599        if (0x20..=0x7E).contains(&byte) {
600            current.push(byte);
601        } else {
602            if current.len() >= 4 {
603                let s = String::from_utf8_lossy(&current).to_string();
604                // Match common AU plugin name patterns
605                // Logic stores plugin names as readable strings near AU type codes
606                if !s.contains('/')
607                    && !s.contains('\\')
608                    && !s.contains("com.apple")
609                    && s.len() >= 4
610                    && s.len() <= 64
611                    && (s.ends_with(".component")
612                        || s.contains("AUPlugin")
613                        || s.contains("AudioUnit"))
614                {
615                    let name = s.trim_end_matches(".component").trim();
616                    if name.len() >= 3 {
617                        let normalized = normalize_plugin_name(name);
618                        if !normalized.is_empty() {
619                            plugins.push(PluginRef {
620                                name: name.to_string(),
621                                normalized_name: normalized,
622                                manufacturer: String::new(),
623                                plugin_type: "AU".into(),
624                            });
625                        }
626                    }
627                }
628            }
629            current.clear();
630        }
631    }
632    plugins
633}
634
635/// Parse Cubase/Nuendo .cpr file (binary — string extraction + Plugin Name markers).
636fn parse_cubase(path: &Path) -> Vec<PluginRef> {
637    let data = match fs::read(path) {
638        Ok(d) => d,
639        Err(_) => return vec![],
640    };
641    let mut plugins = extract_plugins_from_binary(&data);
642    // Cubase stores plugin names after "Plugin Name" markers
643    plugins.extend(extract_named_plugins(&data, b"Plugin Name"));
644    plugins
645}
646
647/// Parse Pro Tools .ptx/.ptf file.
648/// Note: .ptf files (Pro Tools 7-10) are XOR-encrypted and require decryption.
649/// .ptx files (Pro Tools 10+) use a different format.
650/// Both are attempted via string extraction; encrypted files will yield 0 results.
651fn parse_protools(path: &Path) -> Vec<PluginRef> {
652    let data = match fs::read(path) {
653        Ok(d) => d,
654        Err(_) => return vec![],
655    };
656    let mut plugins = extract_plugins_from_binary(&data);
657    // Pro Tools also stores plugin names near specific markers
658    plugins.extend(extract_named_plugins(&data, b"PlugIn Name"));
659    plugins.extend(extract_named_plugins(&data, b"Insert Name"));
660    plugins
661}
662
663/// Parse Reason .reason file (binary — string extraction).
664fn parse_reason(path: &Path) -> Vec<PluginRef> {
665    let data = match fs::read(path) {
666        Ok(d) => d,
667        Err(_) => return vec![],
668    };
669    extract_plugins_from_binary(&data)
670}
671
672// ── Shared extraction helpers ──
673
674/// Extract plugin names from XML using pre-compiled regex patterns.
675fn extract_plugins_from_xml(xml: &str, patterns: &[(&Regex, &str, &str)]) -> Vec<PluginRef> {
676    let mut plugins = Vec::new();
677    for &(re, manufacturer_default, type_default) in patterns {
678        for cap in re.captures_iter(xml) {
679            if let Some(name) = cap.get(1) {
680                let n = name.as_str().trim();
681                if n.is_empty() || n.len() < 2 {
682                    continue;
683                }
684                let normalized = normalize_plugin_name(n);
685                if normalized.is_empty() {
686                    continue;
687                }
688                plugins.push(PluginRef {
689                    name: n.to_string(),
690                    normalized_name: normalized,
691                    manufacturer: manufacturer_default.to_string(),
692                    plugin_type: type_default.to_string(),
693                });
694            }
695        }
696    }
697    plugins
698}
699
700/// Extract plugin references from a binary file via string scanning.
701/// Looks for paths ending in .dll, .vst3, .component, .clap, .aaxplugin
702fn extract_plugins_from_binary(data: &[u8]) -> Vec<PluginRef> {
703    let mut plugins = Vec::new();
704    let mut current = Vec::new();
705    for &byte in data {
706        if (0x20..=0x7E).contains(&byte) {
707            current.push(byte);
708        } else {
709            if current.len() >= 6 {
710                let s = String::from_utf8_lossy(&current).to_string();
711                if let Some(p) = extract_plugin_from_string(&s) {
712                    plugins.push(p);
713                }
714            }
715            current.clear();
716        }
717    }
718    if current.len() >= 6 {
719        let s = String::from_utf8_lossy(&current).to_string();
720        if let Some(p) = extract_plugin_from_string(&s) {
721            plugins.push(p);
722        }
723    }
724    plugins
725}
726
727/// Extract plugins from all files in a directory (for .logicx packages).
728fn extract_plugins_from_dir(dir: &Path) -> Vec<PluginRef> {
729    let mut plugins = Vec::new();
730    if let Ok(entries) = fs::read_dir(dir) {
731        for entry in entries.flatten() {
732            let p = entry.path();
733            if p.is_file() {
734                if let Ok(data) = fs::read(&p) {
735                    plugins.extend(extract_plugins_from_binary(&data));
736                }
737            } else if p.is_dir() && plugins.len() < 500 {
738                plugins.extend(extract_plugins_from_dir(&p));
739            }
740        }
741    }
742    plugins
743}
744
745/// Extract plugin names from a Logic Pro plist structure.
746fn extract_plugins_from_plist(val: &plist::Value, plugins: &mut Vec<PluginRef>) {
747    match val {
748        plist::Value::Dictionary(dict) => {
749            // Look for plugin name keys
750            for key in ["pluginName", "PluginName", "name", "Name", "plugName"] {
751                if let Some(plist::Value::String(name)) = dict.get(key) {
752                    let n = name.trim();
753                    if n.len() >= 2 {
754                        let normalized = normalize_plugin_name(n);
755                        if !normalized.is_empty() {
756                            plugins.push(PluginRef {
757                                name: n.to_string(),
758                                normalized_name: normalized,
759                                manufacturer: dict
760                                    .get("manufacturer")
761                                    .and_then(|v| v.as_string())
762                                    .unwrap_or("")
763                                    .to_string(),
764                                plugin_type: dict
765                                    .get("pluginType")
766                                    .and_then(|v| v.as_string())
767                                    .map(|s| s.to_string())
768                                    .unwrap_or_else(|| "AU".into()),
769                            });
770                        }
771                    }
772                }
773            }
774            for (_, v) in dict.iter() {
775                extract_plugins_from_plist(v, plugins);
776            }
777        }
778        plist::Value::Array(arr) => {
779            for v in arr {
780                extract_plugins_from_plist(v, plugins);
781            }
782        }
783        _ => {}
784    }
785}
786
787/// Try to extract a plugin reference from a single string (path or name).
788/// Handles both exact suffix match and embedded paths (e.g. "Serum.dll8" in FLP chunks).
789fn extract_plugin_from_string(s: &str) -> Option<PluginRef> {
790    let exts = [
791        (".dll", "VST2"),
792        (".vst3", "VST3"),
793        (".component", "AU"),
794        (".clap", "CLAP"),
795        (".aaxplugin", "AAX"),
796    ];
797    for (ext, ptype) in &exts {
798        // Find the extension anywhere in the string (not just at the end)
799        if let Some(pos) = s.find(ext) {
800            // Extract the substring up to and including the extension
801            let path_part = &s[..pos + ext.len()];
802            let name = path_part
803                .rsplit(['\\', '/'])
804                .next()?
805                .trim_end_matches(ext)
806                .trim();
807            if name.is_empty() || name.len() < 2 {
808                continue;
809            }
810            if name.contains("VstPlugins")
811                || name.contains("Program Files")
812                || name.contains("CommonFiles")
813            {
814                continue;
815            }
816            let normalized = normalize_plugin_name(name);
817            if normalized.is_empty() {
818                continue;
819            }
820            return Some(PluginRef {
821                name: name.to_string(),
822                normalized_name: normalized,
823                manufacturer: String::new(),
824                plugin_type: ptype.to_string(),
825            });
826        }
827    }
828    None
829}
830
831/// Extract plugin names that follow a marker string in binary data.
832/// Used by Cubase (.cpr) where plugins appear as "Plugin Name" followed by the name.
833fn extract_named_plugins(data: &[u8], marker: &[u8]) -> Vec<PluginRef> {
834    let mut plugins = Vec::new();
835    let builtin = [
836        "Standard Panner",
837        "Stereo Combined Panner",
838        "Mono",
839        "Stereo",
840        "No Bus",
841    ];
842    let mut pos = 0;
843    while pos + marker.len() < data.len() {
844        if let Some(idx) = data[pos..].windows(marker.len()).position(|w| w == marker) {
845            let after = pos + idx + marker.len();
846            // Skip non-printable bytes to find the next readable string
847            let mut start = after;
848            while start < data.len() && (data[start] < 0x20 || data[start] > 0x7E) {
849                start += 1;
850            }
851            if start < data.len() {
852                let mut end = start;
853                while end < data.len() && data[end] >= 0x20 && data[end] <= 0x7E {
854                    end += 1;
855                }
856                if end - start >= 3 && end - start <= 100 {
857                    let name = String::from_utf8_lossy(&data[start..end]).to_string();
858                    if !builtin.contains(&name.as_str())
859                        && !name.starts_with("VST")
860                        && !name.contains("Plugin")
861                    {
862                        let normalized = normalize_plugin_name(&name);
863                        if !normalized.is_empty() {
864                            plugins.push(PluginRef {
865                                name: name.clone(),
866                                normalized_name: normalized,
867                                manufacturer: String::new(),
868                                plugin_type: "VST".into(),
869                            });
870                        }
871                    }
872                }
873            }
874            pos = after + 1;
875        } else {
876            break;
877        }
878    }
879    plugins
880}
881
882/// Parse Bitwig .bwproject file (binary — reuses shared string extraction).
883fn parse_bitwig(path: &Path) -> Vec<PluginRef> {
884    let data = match fs::read(path) {
885        Ok(d) => d,
886        Err(_) => return vec![],
887    };
888    extract_plugins_from_binary(&data)
889}
890
891#[cfg(test)]
892mod tests {
893    use super::*;
894    use flate2::write::GzEncoder;
895    use flate2::Compression;
896    use std::io::Write;
897
898    #[test]
899    fn test_normalize_plugin_name_whitespace_only() {
900        assert_eq!(normalize_plugin_name("   "), "");
901    }
902
903    #[test]
904    fn test_plugin_ref_json_roundtrip() {
905        let p = PluginRef {
906            name: "Serum".into(),
907            normalized_name: "serum".into(),
908            manufacturer: "Xfer Records".into(),
909            plugin_type: "VST3".into(),
910        };
911        let json = serde_json::to_string(&p).unwrap();
912        let back: PluginRef = serde_json::from_str(&json).unwrap();
913        assert_eq!(p, back);
914    }
915
916    #[test]
917    fn test_extract_empty_for_unsupported() {
918        let result = extract_plugins("/some/file.flp");
919        assert!(result.is_empty());
920    }
921
922    #[test]
923    fn test_extract_nonexistent_file() {
924        let result = extract_plugins("/nonexistent/project.als");
925        assert!(result.is_empty());
926    }
927
928    #[test]
929    fn test_parse_ableton_vst2() {
930        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
931<Ableton>
932  <LiveSet>
933    <Tracks>
934      <MidiTrack>
935        <DeviceChain>
936          <Devices>
937            <PluginDevice>
938              <PluginDesc>
939                <VstPluginInfo>
940                  <PlugName Value="Serum" />
941                  <Manufacturer Value="Xfer Records" />
942                </VstPluginInfo>
943              </PluginDesc>
944            </PluginDevice>
945          </Devices>
946        </DeviceChain>
947      </MidiTrack>
948    </Tracks>
949  </LiveSet>
950</Ableton>"#;
951
952        let tmp = std::env::temp_dir().join("test_xref_als_vst2.als");
953        let f = fs::File::create(&tmp).unwrap();
954        let mut enc = GzEncoder::new(f, Compression::default());
955        enc.write_all(xml.as_bytes()).unwrap();
956        enc.finish().unwrap();
957
958        let result = extract_plugins(tmp.to_str().unwrap());
959        assert_eq!(result.len(), 1);
960        assert_eq!(result[0].name, "Serum");
961        assert_eq!(result[0].manufacturer, "Xfer Records");
962        assert_eq!(result[0].plugin_type, "VST2");
963
964        let _ = fs::remove_file(&tmp);
965    }
966
967    #[test]
968    fn test_parse_ableton_vst3() {
969        let xml = r#"<Ableton>
970  <Vst3PluginInfo>
971    <Name Value="Pro-Q 3" />
972    <DeviceCreator Value="FabFilter" />
973  </Vst3PluginInfo>
974</Ableton>"#;
975
976        let tmp = std::env::temp_dir().join("test_xref_als_vst3.als");
977        let f = fs::File::create(&tmp).unwrap();
978        let mut enc = GzEncoder::new(f, Compression::default());
979        enc.write_all(xml.as_bytes()).unwrap();
980        enc.finish().unwrap();
981
982        let result = extract_plugins(tmp.to_str().unwrap());
983        assert_eq!(result.len(), 1);
984        assert_eq!(result[0].name, "Pro-Q 3");
985        assert_eq!(result[0].manufacturer, "FabFilter");
986        assert_eq!(result[0].plugin_type, "VST3");
987
988        let _ = fs::remove_file(&tmp);
989    }
990
991    #[test]
992    fn test_parse_ableton_au() {
993        let xml = r#"<Ableton>
994  <AuPluginInfo>
995    <Name Value="AUReverb2" />
996    <Manufacturer Value="Apple" />
997  </AuPluginInfo>
998</Ableton>"#;
999
1000        let tmp = std::env::temp_dir().join("test_xref_als_au.als");
1001        let f = fs::File::create(&tmp).unwrap();
1002        let mut enc = GzEncoder::new(f, Compression::default());
1003        enc.write_all(xml.as_bytes()).unwrap();
1004        enc.finish().unwrap();
1005
1006        let result = extract_plugins(tmp.to_str().unwrap());
1007        assert_eq!(result.len(), 1);
1008        assert_eq!(result[0].name, "AUReverb2");
1009        assert_eq!(result[0].plugin_type, "AU");
1010
1011        let _ = fs::remove_file(&tmp);
1012    }
1013
1014    #[test]
1015    fn test_parse_ableton_multiple_deduped() {
1016        let xml = r#"<Ableton>
1017  <VstPluginInfo><PlugName Value="Serum" /><Manufacturer Value="Xfer" /></VstPluginInfo>
1018  <VstPluginInfo><PlugName Value="Serum" /><Manufacturer Value="Xfer" /></VstPluginInfo>
1019  <Vst3PluginInfo><Name Value="Pro-Q 3" /><DeviceCreator Value="FabFilter" /></Vst3PluginInfo>
1020</Ableton>"#;
1021
1022        let tmp = std::env::temp_dir().join("test_xref_als_multi.als");
1023        let f = fs::File::create(&tmp).unwrap();
1024        let mut enc = GzEncoder::new(f, Compression::default());
1025        enc.write_all(xml.as_bytes()).unwrap();
1026        enc.finish().unwrap();
1027
1028        let result = extract_plugins(tmp.to_str().unwrap());
1029        assert_eq!(result.len(), 2); // Serum deduped
1030        assert!(result.iter().any(|p| p.name == "Serum"));
1031        assert!(result.iter().any(|p| p.name == "Pro-Q 3"));
1032
1033        let _ = fs::remove_file(&tmp);
1034    }
1035
1036    #[test]
1037    fn test_parse_reaper_vst2() {
1038        let rpp = r#"<REAPER_PROJECT 0.1 "7.0"
1039  <TRACK
1040    <FXCHAIN
1041      <VST "VST: Serum (Xfer Records)" Serum_x64.dll 0 "" 1397572658
1042      >
1043    >
1044  >
1045>"#;
1046        let tmp = std::env::temp_dir().join("test_xref_rpp_vst2.rpp");
1047        fs::write(&tmp, rpp).unwrap();
1048
1049        let result = extract_plugins(tmp.to_str().unwrap());
1050        assert_eq!(result.len(), 1);
1051        assert_eq!(result[0].name, "Serum");
1052        assert_eq!(result[0].manufacturer, "Xfer Records");
1053        assert_eq!(result[0].plugin_type, "VST2");
1054
1055        let _ = fs::remove_file(&tmp);
1056    }
1057
1058    #[test]
1059    fn test_parse_reaper_vst3() {
1060        let rpp = r#"<REAPER_PROJECT
1061  <TRACK
1062    <FXCHAIN
1063      <VST "VST3: Pro-Q 3 (FabFilter)" "{ABCDEF}" 0
1064      >
1065    >
1066  >
1067>"#;
1068        let tmp = std::env::temp_dir().join("test_xref_rpp_vst3.rpp");
1069        fs::write(&tmp, rpp).unwrap();
1070
1071        let result = extract_plugins(tmp.to_str().unwrap());
1072        assert_eq!(result.len(), 1);
1073        assert_eq!(result[0].name, "Pro-Q 3");
1074        assert_eq!(result[0].manufacturer, "FabFilter");
1075        assert_eq!(result[0].plugin_type, "VST3");
1076
1077        let _ = fs::remove_file(&tmp);
1078    }
1079
1080    #[test]
1081    fn test_parse_reaper_mixed() {
1082        let rpp = r#"<REAPER_PROJECT
1083  <TRACK
1084    <FXCHAIN
1085      <VST "VST: Serum (Xfer Records)" Serum.dll 0 "" 123
1086      >
1087      <VST "VST3: Ozone 11 (iZotope, Inc.)" Ozone.vst3 0 "" 456
1088      >
1089      <AU "AU: AUHighShelfFilter (Apple)" "" 0 "" 789
1090      >
1091    >
1092  >
1093>"#;
1094        let tmp = std::env::temp_dir().join("test_xref_rpp_mixed.rpp");
1095        fs::write(&tmp, rpp).unwrap();
1096
1097        let result = extract_plugins(tmp.to_str().unwrap());
1098        assert_eq!(result.len(), 3);
1099        assert!(result
1100            .iter()
1101            .any(|p| p.name == "Serum" && p.plugin_type == "VST2"));
1102        assert!(result
1103            .iter()
1104            .any(|p| p.name == "Ozone 11" && p.plugin_type == "VST3"));
1105        assert!(result
1106            .iter()
1107            .any(|p| p.name == "AUHighShelfFilter" && p.plugin_type == "AU"));
1108
1109        let _ = fs::remove_file(&tmp);
1110    }
1111
1112    #[test]
1113    fn test_parse_reaper_no_manufacturer() {
1114        let rpp = r#"<REAPER_PROJECT
1115  <TRACK
1116    <FXCHAIN
1117      <VST "VST: ReaComp" reacomp.dll 0
1118      >
1119    >
1120  >
1121>"#;
1122        let tmp = std::env::temp_dir().join("test_xref_rpp_nomfg.rpp");
1123        fs::write(&tmp, rpp).unwrap();
1124
1125        let result = extract_plugins(tmp.to_str().unwrap());
1126        assert_eq!(result.len(), 1);
1127        assert_eq!(result[0].name, "ReaComp");
1128        assert_eq!(result[0].manufacturer, "");
1129
1130        let _ = fs::remove_file(&tmp);
1131    }
1132
1133    #[test]
1134    fn test_parse_reaper_deduplicates() {
1135        let rpp = r#"<REAPER_PROJECT
1136  <TRACK
1137    <FXCHAIN
1138      <VST "VST: Serum (Xfer Records)" Serum.dll 0 "" 123
1139      >
1140    >
1141  >
1142  <TRACK
1143    <FXCHAIN
1144      <VST "VST: Serum (Xfer Records)" Serum.dll 0 "" 123
1145      >
1146    >
1147  >
1148>"#;
1149        let tmp = std::env::temp_dir().join("test_xref_rpp_dedup.rpp");
1150        fs::write(&tmp, rpp).unwrap();
1151
1152        let result = extract_plugins(tmp.to_str().unwrap());
1153        assert_eq!(result.len(), 1);
1154
1155        let _ = fs::remove_file(&tmp);
1156    }
1157
1158    #[test]
1159    fn test_parse_reaper_empty_fx_chain() {
1160        let rpp = r#"<REAPER_PROJECT
1161  <TRACK
1162    <FXCHAIN
1163    >
1164  >
1165>"#;
1166        let tmp = std::env::temp_dir().join("test_xref_rpp_empty.rpp");
1167        fs::write(&tmp, rpp).unwrap();
1168
1169        let result = extract_plugins(tmp.to_str().unwrap());
1170        assert!(result.is_empty());
1171
1172        let _ = fs::remove_file(&tmp);
1173    }
1174
1175    #[test]
1176    fn test_parse_ableton_not_gzip() {
1177        let tmp = std::env::temp_dir().join("test_xref_als_bad.als");
1178        fs::write(&tmp, b"not gzip data").unwrap();
1179
1180        let result = extract_plugins(tmp.to_str().unwrap());
1181        assert!(result.is_empty());
1182
1183        let _ = fs::remove_file(&tmp);
1184    }
1185
1186    #[test]
1187    fn test_parse_ableton_empty_xml() {
1188        // Valid gzip but no plugin blocks at all
1189        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1190<Ableton>
1191  <LiveSet>
1192    <Tracks>
1193      <AudioTrack>
1194        <DeviceChain>
1195          <Devices />
1196        </DeviceChain>
1197      </AudioTrack>
1198    </Tracks>
1199  </LiveSet>
1200</Ableton>"#;
1201
1202        let tmp = std::env::temp_dir().join("test_xref_als_empty_xml.als");
1203        let f = fs::File::create(&tmp).unwrap();
1204        let mut enc = GzEncoder::new(f, Compression::default());
1205        enc.write_all(xml.as_bytes()).unwrap();
1206        enc.finish().unwrap();
1207
1208        let result = extract_plugins(tmp.to_str().unwrap());
1209        assert!(
1210            result.is_empty(),
1211            "No plugin blocks should yield empty result"
1212        );
1213
1214        let _ = fs::remove_file(&tmp);
1215    }
1216
1217    #[test]
1218    fn test_parse_reaper_clap() {
1219        let rpp = r#"<REAPER_PROJECT
1220  <TRACK
1221    <FXCHAIN
1222      <CLAP "CLAP: Surge XT (Surge Synth Team)" com.surge-synth-team.surge-xt 0
1223      >
1224    >
1225  >
1226>"#;
1227        let tmp = std::env::temp_dir().join("test_xref_rpp_clap.rpp");
1228        fs::write(&tmp, rpp).unwrap();
1229
1230        let result = extract_plugins(tmp.to_str().unwrap());
1231        assert_eq!(result.len(), 1);
1232        assert_eq!(result[0].name, "Surge XT");
1233        assert_eq!(result[0].manufacturer, "Surge Synth Team");
1234        assert_eq!(result[0].plugin_type, "CLAP");
1235
1236        let _ = fs::remove_file(&tmp);
1237    }
1238
1239    #[test]
1240    fn test_extract_rpp_bak_extension() {
1241        let rpp = r#"<REAPER_PROJECT
1242  <TRACK
1243    <FXCHAIN
1244      <VST "VST: Vital (Matt Tytel)" Vital.dll 0 "" 999
1245      >
1246    >
1247  >
1248>"#;
1249        let tmp = std::env::temp_dir().join("test_xref.rpp-bak");
1250        fs::write(&tmp, rpp).unwrap();
1251
1252        let result = extract_plugins(tmp.to_str().unwrap());
1253        assert_eq!(result.len(), 1, ".rpp-bak should be treated as REAPER");
1254        assert_eq!(result[0].name, "Vital");
1255        assert_eq!(result[0].plugin_type, "VST2");
1256
1257        let _ = fs::remove_file(&tmp);
1258    }
1259
1260    #[test]
1261    fn test_normalize_plugin_name_basic() {
1262        assert_eq!(normalize_plugin_name("Serum"), "serum");
1263        assert_eq!(normalize_plugin_name("Pro-Q 3"), "pro-q 3");
1264        assert_eq!(normalize_plugin_name("  Diva  "), "diva");
1265    }
1266
1267    #[test]
1268    fn test_normalize_plugin_name_mixed_case_collapses_to_lowercase() {
1269        assert_eq!(
1270            normalize_plugin_name("FabFilter Pro-Q 3"),
1271            "fabfilter pro-q 3"
1272        );
1273    }
1274
1275    #[test]
1276    fn test_normalize_strips_arch_suffixes() {
1277        assert_eq!(normalize_plugin_name("Serum (x64)"), "serum");
1278        assert_eq!(normalize_plugin_name("Kontakt (x86_64)"), "kontakt");
1279        assert_eq!(normalize_plugin_name("Massive (64-bit)"), "massive");
1280        assert_eq!(normalize_plugin_name("Sylenth1 (32-bit)"), "sylenth1");
1281        assert_eq!(normalize_plugin_name("Reaktor (ARM64)"), "reaktor");
1282        assert_eq!(
1283            normalize_plugin_name("Omnisphere (Universal)"),
1284            "omnisphere"
1285        );
1286        assert_eq!(normalize_plugin_name("Pigments [x64]"), "pigments");
1287        assert_eq!(normalize_plugin_name("Vital (Stereo)"), "vital");
1288    }
1289
1290    #[test]
1291    fn test_normalize_strips_bare_arch_suffix() {
1292        assert_eq!(normalize_plugin_name("Serum x64"), "serum");
1293        assert_eq!(normalize_plugin_name("Kontakt x86_64"), "kontakt");
1294        assert_eq!(normalize_plugin_name("Massive x86"), "massive");
1295    }
1296
1297    #[test]
1298    fn test_normalize_strips_multiple_suffixes() {
1299        assert_eq!(normalize_plugin_name("Serum (x64) (VST3)"), "serum");
1300        assert_eq!(normalize_plugin_name("Kontakt (Stereo) (x64)"), "kontakt");
1301    }
1302
1303    #[test]
1304    fn test_normalize_preserves_inner_parens() {
1305        assert_eq!(normalize_plugin_name("EQ (3-band)"), "eq (3-band)");
1306        assert_eq!(
1307            normalize_plugin_name("Compressor (Legacy)"),
1308            "compressor (legacy)"
1309        );
1310    }
1311
1312    #[test]
1313    fn test_normalize_collapses_whitespace() {
1314        assert_eq!(normalize_plugin_name("Pro   Q  3"), "pro q 3");
1315    }
1316
1317    #[test]
1318    fn test_normalize_all_suffix_fallback() {
1319        // If stripping arch suffixes removes everything, fall back to original
1320        assert_eq!(normalize_plugin_name("(x64)"), "(x64)");
1321        assert_eq!(normalize_plugin_name("(x64) (VST3)"), "(x64) (vst3)");
1322        // Empty/whitespace input
1323        assert_eq!(normalize_plugin_name(""), "");
1324        assert_eq!(normalize_plugin_name("   "), "");
1325    }
1326
1327    #[test]
1328    fn test_normalize_strips_au_vst_aax_brackets() {
1329        assert_eq!(normalize_plugin_name("Pro-Q 3 (AU)"), "pro-q 3");
1330        assert_eq!(normalize_plugin_name("Serum (VST3)"), "serum");
1331        assert_eq!(normalize_plugin_name("Tune (AAX)"), "tune");
1332    }
1333
1334    #[test]
1335    fn test_normalize_intel_bracket() {
1336        assert_eq!(normalize_plugin_name("Legacy (Intel)"), "legacy");
1337    }
1338
1339    #[test]
1340    fn test_normalize_strips_apple_silicon_bracket() {
1341        assert_eq!(
1342            normalize_plugin_name("Melodyne (Apple Silicon)"),
1343            "melodyne"
1344        );
1345    }
1346
1347    #[test]
1348    fn test_normalize_equivalent_after_strip() {
1349        assert_eq!(
1350            normalize_plugin_name("Massive (x64)"),
1351            normalize_plugin_name("Massive x64")
1352        );
1353    }
1354
1355    #[test]
1356    fn test_extract_plugin_from_string_embedded_windows_path() {
1357        let s = r"C:\VSTPlugins\Xfer\Serum.vst3";
1358        let p = extract_plugin_from_string(s).expect("vst3 in path");
1359        assert_eq!(p.name, "Serum");
1360        assert_eq!(p.plugin_type, "VST3");
1361        assert_eq!(p.normalized_name, "serum");
1362    }
1363
1364    #[test]
1365    fn test_extract_plugin_from_string_skips_vstplugins_substring_in_stem() {
1366        // `name` is the file stem; embedded "VstPlugins" skips generic installer noise
1367        let s = r"C:\Plugins\SerumVstPluginsBundle.vst3";
1368        assert!(extract_plugin_from_string(s).is_none());
1369    }
1370
1371    #[test]
1372    fn test_extract_plugin_from_string_short_stem_skipped() {
1373        let s = r"C:\p\X.vst3";
1374        assert!(extract_plugin_from_string(s).is_none());
1375    }
1376
1377    #[test]
1378    fn test_extract_plugin_from_string_clap_suffix() {
1379        let s = "/lib/CLAP/Pigments.clap";
1380        let p = extract_plugin_from_string(s).unwrap();
1381        assert_eq!(p.name, "Pigments");
1382        assert_eq!(p.plugin_type, "CLAP");
1383    }
1384
1385    #[test]
1386    fn test_extract_au_identifiers_component_bundle_name() {
1387        let mut data = b"MyDelay.component".to_vec();
1388        data.push(0);
1389        let refs = extract_au_identifiers(&data);
1390        assert_eq!(refs.len(), 1);
1391        assert_eq!(refs[0].name, "MyDelay");
1392        assert_eq!(refs[0].plugin_type, "AU");
1393    }
1394
1395    #[test]
1396    fn test_extract_plugins_from_xml_regex_capture() {
1397        // Avoid attributes like deviceName= — substring `name="` would match inside it first
1398        let xml = r#"<Plugin name="Serum" />"#;
1399        let re = Regex::new(r#"name="([^"]+)""#).unwrap();
1400        let patterns: &[(&Regex, &str, &str)] = &[(&re, "Xfer Records", "VST3")];
1401        let refs = extract_plugins_from_xml(xml, patterns);
1402        assert_eq!(refs.len(), 1);
1403        assert_eq!(refs[0].name, "Serum");
1404        assert_eq!(refs[0].manufacturer, "Xfer Records");
1405        assert_eq!(refs[0].plugin_type, "VST3");
1406    }
1407
1408    #[test]
1409    fn test_extract_plugins_from_xml_skips_single_char_name() {
1410        let xml = r#"<x name="X" />"#;
1411        let re = Regex::new(r#"name="([^"]+)""#).unwrap();
1412        let patterns: &[(&Regex, &str, &str)] = &[(&re, "Co", "VST3")];
1413        assert!(
1414            extract_plugins_from_xml(xml, patterns).is_empty(),
1415            "stem length < 2 must be skipped"
1416        );
1417    }
1418
1419    #[test]
1420    fn test_dedup_case_insensitive() {
1421        let rpp = r#"<REAPER_PROJECT
1422  <TRACK
1423    <FXCHAIN
1424      <VST "VST: Serum (Xfer Records)" Serum.dll 0 "" 123
1425      >
1426      <VST "VST: SERUM (Xfer Records)" Serum.dll 0 "" 456
1427      >
1428      <VST "VST: serum (Xfer)" Serum.dll 0 "" 789
1429      >
1430    >
1431  >
1432>"#;
1433        let tmp = std::env::temp_dir().join("test_xref_rpp_case_dedup.rpp");
1434        fs::write(&tmp, rpp).unwrap();
1435
1436        let result = extract_plugins(tmp.to_str().unwrap());
1437        assert_eq!(result.len(), 1, "case variants should dedup to one");
1438        assert_eq!(result[0].normalized_name, "serum");
1439
1440        let _ = fs::remove_file(&tmp);
1441    }
1442
1443    #[test]
1444    fn test_dedup_arch_suffix_variants() {
1445        let rpp = r#"<REAPER_PROJECT
1446  <TRACK
1447    <FXCHAIN
1448      <VST "VST: Serum (Xfer)" Serum.dll 0 "" 1
1449      >
1450      <VST "VST: Serum x64 (Xfer)" Serum_x64.dll 0 "" 2
1451      >
1452    >
1453  >
1454>"#;
1455        let tmp = std::env::temp_dir().join("test_xref_rpp_arch_dedup.rpp");
1456        fs::write(&tmp, rpp).unwrap();
1457
1458        let result = extract_plugins(tmp.to_str().unwrap());
1459        assert_eq!(result.len(), 1, "arch suffix variants should dedup");
1460        assert_eq!(result[0].normalized_name, "serum");
1461
1462        let _ = fs::remove_file(&tmp);
1463    }
1464
1465    #[test]
1466    fn test_results_sorted_by_name() {
1467        let rpp = r#"<REAPER_PROJECT
1468  <TRACK
1469    <FXCHAIN
1470      <VST "VST: Zebra2 (u-he)" z.dll 0 "" 1
1471      >
1472      <VST "VST: Diva (u-he)" d.dll 0 "" 2
1473      >
1474      <VST "VST: Ace (u-he)" a.dll 0 "" 3
1475      >
1476    >
1477  >
1478>"#;
1479        let tmp = std::env::temp_dir().join("test_xref_rpp_sorted.rpp");
1480        fs::write(&tmp, rpp).unwrap();
1481
1482        let result = extract_plugins(tmp.to_str().unwrap());
1483        assert_eq!(result.len(), 3);
1484        assert_eq!(result[0].name, "Ace");
1485        assert_eq!(result[1].name, "Diva");
1486        assert_eq!(result[2].name, "Zebra2");
1487
1488        let _ = fs::remove_file(&tmp);
1489    }
1490
1491    #[test]
1492    fn test_extract_bitwig_plugin_dll() {
1493        let p = extract_plugin_from_string(r"C:\Program Files\Steinberg\VstPlugins\Serum.dll");
1494        assert!(p.is_some());
1495        let p = p.unwrap();
1496        assert_eq!(p.name, "Serum");
1497        assert_eq!(p.plugin_type, "VST2");
1498    }
1499
1500    #[test]
1501    fn test_extract_bitwig_plugin_vst3() {
1502        let p = extract_plugin_from_string("/Library/Audio/Plug-Ins/VST3/FabFilter Pro-Q 3.vst3");
1503        assert!(p.is_some());
1504        let p = p.unwrap();
1505        assert_eq!(p.name, "FabFilter Pro-Q 3");
1506        assert_eq!(p.plugin_type, "VST3");
1507    }
1508
1509    #[test]
1510    fn test_extract_bitwig_plugin_au() {
1511        let p = extract_plugin_from_string("/Library/Audio/Plug-Ins/Components/Massive.component");
1512        assert!(p.is_some());
1513        let p = p.unwrap();
1514        assert_eq!(p.name, "Massive");
1515        assert_eq!(p.plugin_type, "AU");
1516    }
1517
1518    #[test]
1519    fn test_extract_bitwig_plugin_clap() {
1520        let p = extract_plugin_from_string("/Library/Audio/Plug-Ins/CLAP/Vital.clap");
1521        assert!(p.is_some());
1522        let p = p.unwrap();
1523        assert_eq!(p.name, "Vital");
1524        assert_eq!(p.plugin_type, "CLAP");
1525    }
1526
1527    #[test]
1528    fn test_extract_bitwig_plugin_rejects_dir() {
1529        // VstPlugins directory path should not be extracted as a plugin
1530        assert!(extract_plugin_from_string(r"C:\Program Files\Steinberg\VstPlugins").is_none());
1531    }
1532
1533    #[test]
1534    fn test_extract_bitwig_plugin_strips_path() {
1535        let p = extract_plugin_from_string(r"MeldaProduction\Modulation\MFlanger.dll");
1536        assert!(p.is_some());
1537        assert_eq!(p.unwrap().name, "MFlanger");
1538    }
1539
1540    #[test]
1541    fn test_parse_bitwig_synthetic() {
1542        // Create a fake bwproject with embedded plugin strings
1543        let tmp = std::env::temp_dir().join("test_bitwig.bwproject");
1544        let mut data = b"BtWg0003".to_vec();
1545        data.extend_from_slice(&[0u8; 100]); // padding
1546        data.extend_from_slice(b"C:\\VstPlugins\\Serum.dll");
1547        data.extend_from_slice(&[0u8; 20]);
1548        data.extend_from_slice(b"/Library/Audio/Plug-Ins/VST3/Pro-Q 3.vst3");
1549        data.extend_from_slice(&[0u8; 20]);
1550        data.extend_from_slice(b"/Library/Audio/Plug-Ins/Components/Kontakt.component");
1551        data.extend_from_slice(&[0u8; 20]);
1552        fs::write(&tmp, &data).unwrap();
1553
1554        let result = extract_plugins(tmp.to_str().unwrap());
1555        assert!(
1556            result.len() >= 3,
1557            "should find at least 3 plugins, got {}",
1558            result.len()
1559        );
1560        let names: Vec<&str> = result.iter().map(|p| p.name.as_str()).collect();
1561        assert!(
1562            names.contains(&"Serum"),
1563            "should find Serum, got {:?}",
1564            names
1565        );
1566        assert!(
1567            names.contains(&"Pro-Q 3"),
1568            "should find Pro-Q 3, got {:?}",
1569            names
1570        );
1571        assert!(
1572            names.contains(&"Kontakt"),
1573            "should find Kontakt, got {:?}",
1574            names
1575        );
1576
1577        let _ = fs::remove_file(&tmp);
1578    }
1579
1580    // ── Shared extraction tests ──
1581
1582    #[test]
1583    fn test_binary_extraction_all_plugin_types() {
1584        let mut data = vec![0u8; 100];
1585        for (path, expected_type) in [
1586            ("C:\\VSTPlugins\\Massive.dll", "VST2"),
1587            ("/Library/Audio/Plug-Ins/VST3/Serum.vst3", "VST3"),
1588            ("/Library/Audio/Plug-Ins/Components/Kontakt.component", "AU"),
1589            ("/Library/Audio/Plug-Ins/CLAP/Vital.clap", "CLAP"),
1590            ("C:\\AAX\\Pro-Q 3.aaxplugin", "AAX"),
1591        ] {
1592            data.extend_from_slice(path.as_bytes());
1593            data.extend_from_slice(&[0; 50]);
1594            let _ = expected_type;
1595        }
1596        let result = extract_plugins_from_binary(&data);
1597        let types: Vec<&str> = result.iter().map(|p| p.plugin_type.as_str()).collect();
1598        assert!(types.contains(&"VST2"), "missing VST2");
1599        assert!(types.contains(&"VST3"), "missing VST3");
1600        assert!(types.contains(&"AU"), "missing AU");
1601        assert!(types.contains(&"CLAP"), "missing CLAP");
1602        assert!(types.contains(&"AAX"), "missing AAX");
1603    }
1604
1605    #[test]
1606    fn test_extract_plugin_with_trailing_junk() {
1607        // FLP-style: plugin path followed by chunk byte
1608        let p = extract_plugin_from_string("F:\\VSTPlugins\\Serum_x64.dll8");
1609        assert!(p.is_some(), "should extract despite trailing '8'");
1610        assert_eq!(p.unwrap().name, "Serum_x64");
1611    }
1612
1613    #[test]
1614    fn test_extract_plugin_embedded_in_longer_string() {
1615        let p = extract_plugin_from_string("some_prefix/Sylenth1.dll/some_suffix");
1616        assert!(p.is_some());
1617        assert_eq!(p.unwrap().name, "Sylenth1");
1618    }
1619
1620    // ── FLP tests ──
1621
1622    #[test]
1623    fn test_flp_ascii_extraction() {
1624        let mut data = vec![0u8; 100];
1625        data.extend_from_slice(b"C:\\Program Files\\VSTPlugins\\Sylenth1.dll");
1626        data.extend_from_slice(&[0; 50]);
1627        data.extend_from_slice(b"C:\\VST3\\FabFilter Pro-Q 3.vst3");
1628        data.extend_from_slice(&[0; 50]);
1629        let result = extract_plugins_from_binary(&data);
1630        let names: Vec<&str> = result.iter().map(|p| p.name.as_str()).collect();
1631        assert!(names.contains(&"Sylenth1"), "missing Sylenth1: {:?}", names);
1632        assert!(
1633            names.contains(&"FabFilter Pro-Q 3"),
1634            "missing Pro-Q 3: {:?}",
1635            names
1636        );
1637    }
1638
1639    #[test]
1640    fn test_flp_utf16le_extraction() {
1641        let mut data = vec![0u8; 50];
1642        // "Serum_x64.dll" as UTF-16LE
1643        for c in "Serum_x64.dll".chars() {
1644            data.push(c as u8);
1645            data.push(0);
1646        }
1647        data.extend_from_slice(&[0; 50]);
1648        let result = extract_plugins_utf16le(&data);
1649        assert!(!result.is_empty(), "UTF-16LE extraction failed");
1650        assert_eq!(result[0].name, "Serum_x64");
1651    }
1652
1653    #[test]
1654    fn test_flp_combined_ascii_and_utf16() {
1655        let tmp = std::env::temp_dir().join("test_xref_flp_combined.flp");
1656        let mut data = vec![0u8; 100];
1657        data.extend_from_slice(b"C:\\Plugins\\OTT.dll");
1658        data.extend_from_slice(&[0; 30]);
1659        for c in "F:\\VSTPlugins\\Massive.dll".chars() {
1660            data.push(c as u8);
1661            data.push(0);
1662        }
1663        data.extend_from_slice(&[0; 50]);
1664        fs::write(&tmp, &data).unwrap();
1665        let result = parse_flp(&tmp);
1666        let names: Vec<&str> = result.iter().map(|p| p.name.as_str()).collect();
1667        assert!(
1668            names.iter().any(|n| n.contains("OTT")),
1669            "missing OTT: {:?}",
1670            names
1671        );
1672        assert!(
1673            names.iter().any(|n| n.contains("Massive")),
1674            "missing Massive: {:?}",
1675            names
1676        );
1677        let _ = fs::remove_file(&tmp);
1678    }
1679
1680    // ── Cubase tests ──
1681
1682    #[test]
1683    fn test_cubase_plugin_name_markers() {
1684        let mut data = vec![0u8; 50];
1685        data.extend_from_slice(b"Plugin Name");
1686        data.push(0);
1687        data.extend_from_slice(b"Spire-1.5");
1688        data.extend_from_slice(&[0; 30]);
1689        data.extend_from_slice(b"Plugin Name");
1690        data.push(0);
1691        data.extend_from_slice(b"LFOTool");
1692        data.extend_from_slice(&[0; 30]);
1693        // Should skip builtin
1694        data.extend_from_slice(b"Plugin Name");
1695        data.push(0);
1696        data.extend_from_slice(b"Standard Panner");
1697        data.extend_from_slice(&[0; 30]);
1698        let result = extract_named_plugins(&data, b"Plugin Name");
1699        let names: Vec<&str> = result.iter().map(|p| p.name.as_str()).collect();
1700        assert!(names.contains(&"Spire-1.5"), "missing Spire: {:?}", names);
1701        assert!(names.contains(&"LFOTool"), "missing LFOTool: {:?}", names);
1702        assert!(
1703            !names.contains(&"Standard Panner"),
1704            "should filter Standard Panner"
1705        );
1706    }
1707
1708    #[test]
1709    fn test_cubase_binary_paths_plus_markers() {
1710        let mut data = vec![0u8; 50];
1711        data.extend_from_slice(b"C:\\VST3\\Serum.vst3");
1712        data.extend_from_slice(&[0; 30]);
1713        data.extend_from_slice(b"Plugin Name");
1714        data.push(0);
1715        data.extend_from_slice(b"LFOTool");
1716        data.extend_from_slice(&[0; 30]);
1717        let tmp = std::env::temp_dir().join("test_xref_cubase.cpr");
1718        fs::write(&tmp, &data).unwrap();
1719        let result = extract_plugins(tmp.to_str().unwrap());
1720        let names: Vec<&str> = result.iter().map(|p| p.name.as_str()).collect();
1721        assert!(
1722            names.contains(&"Serum"),
1723            "missing Serum from binary: {:?}",
1724            names
1725        );
1726        assert!(
1727            names.contains(&"LFOTool"),
1728            "missing LFOTool from marker: {:?}",
1729            names
1730        );
1731        let _ = fs::remove_file(&tmp);
1732    }
1733
1734    // ── Logic tests ──
1735
1736    #[test]
1737    fn test_logic_known_plugins_extraction() {
1738        let mut data = vec![0u8; 50];
1739        // Embed known plugin names as standalone strings
1740        for name in ["Sylenth1", "Channel EQ", "Compressor", "Alchemy", "Hive"] {
1741            data.extend_from_slice(name.as_bytes());
1742            data.extend_from_slice(&[0; 10]);
1743        }
1744        let result = extract_logic_plugin_names(&data);
1745        let names: Vec<&str> = result.iter().map(|p| p.name.as_str()).collect();
1746        assert!(names.contains(&"Sylenth1"), "missing Sylenth1: {:?}", names);
1747        assert!(
1748            names.contains(&"Channel EQ"),
1749            "missing Channel EQ: {:?}",
1750            names
1751        );
1752        assert!(
1753            names.contains(&"Compressor"),
1754            "missing Compressor: {:?}",
1755            names
1756        );
1757        assert!(names.contains(&"Alchemy"), "missing Alchemy: {:?}", names);
1758        assert!(names.contains(&"Hive"), "missing Hive: {:?}", names);
1759    }
1760
1761    #[test]
1762    fn test_logic_filters_false_positives() {
1763        let mut data = vec![0u8; 50];
1764        for name in [
1765            "Output 1",
1766            "Output 5-6H",
1767            "Automatic-Generic Audio 12",
1768            "com.apple.foo",
1769        ] {
1770            data.extend_from_slice(name.as_bytes());
1771            data.extend_from_slice(&[0; 10]);
1772        }
1773        let result = extract_logic_plugin_names(&data);
1774        assert!(
1775            result.is_empty(),
1776            "should filter all false positives, got: {:?}",
1777            result.iter().map(|p| &p.name).collect::<Vec<_>>()
1778        );
1779    }
1780
1781    #[test]
1782    fn test_logic_stock_vs_thirdparty_type() {
1783        let mut data = vec![0u8; 50];
1784        data.extend_from_slice(b"Channel EQ");
1785        data.extend_from_slice(&[0; 10]);
1786        data.extend_from_slice(b"Sylenth1");
1787        data.extend_from_slice(&[0; 10]);
1788        let result = extract_logic_plugin_names(&data);
1789        let stock = result.iter().find(|p| p.name == "Channel EQ").unwrap();
1790        let third = result.iter().find(|p| p.name == "Sylenth1").unwrap();
1791        assert_eq!(stock.plugin_type, "AU (Stock)");
1792        assert_eq!(third.plugin_type, "AU");
1793    }
1794
1795    #[test]
1796    fn test_logic_component_path_extraction() {
1797        let mut data = vec![0u8; 50];
1798        data.extend_from_slice(b"/Library/Audio/Plug-Ins/Components/FabFilter Pro-Q 3.component");
1799        data.extend_from_slice(&[0; 50]);
1800        let result = extract_plugins_from_binary(&data);
1801        assert!(!result.is_empty());
1802        assert_eq!(result[0].name, "FabFilter Pro-Q 3");
1803        assert_eq!(result[0].plugin_type, "AU");
1804    }
1805
1806    // ── Studio One tests ──
1807
1808    #[test]
1809    fn test_studio_one_zip_xml() {
1810        use std::io::Write;
1811        let tmp = std::env::temp_dir().join("test_xref_s1.song");
1812        let file = fs::File::create(&tmp).unwrap();
1813        let mut zip = zip::ZipWriter::new(file);
1814        zip.start_file::<_, ()>("Song/song.xml", Default::default())
1815            .unwrap();
1816        zip.write_all(b"<Song><MediaTrack name=\"Bass\"/></Song>")
1817            .unwrap();
1818        zip.start_file::<_, ()>("Devices/audiomixer.xml", Default::default())
1819            .unwrap();
1820        zip.write_all(
1821            b"<AudioMixer><Insert plugName=\"Pro-Q 3\" deviceName=\"FabFilter\"/></AudioMixer>",
1822        )
1823        .unwrap();
1824        zip.finish().unwrap();
1825        let result = extract_plugins(tmp.to_str().unwrap());
1826        let names: Vec<&str> = result.iter().map(|p| p.name.as_str()).collect();
1827        assert!(names.contains(&"Pro-Q 3"), "missing Pro-Q 3: {:?}", names);
1828        assert!(
1829            !names.iter().any(|n| *n == "FabFilter"),
1830            "deviceName is vendor/model, not a second plugin: {:?}",
1831            names
1832        );
1833        let _ = fs::remove_file(&tmp);
1834    }
1835
1836    #[test]
1837    fn test_studio_one_ignores_generic_label_attributes() {
1838        use std::io::Write;
1839        let tmp = std::env::temp_dir().join("test_xref_s1_labels.song");
1840        let file = fs::File::create(&tmp).unwrap();
1841        let mut zip = zip::ZipWriter::new(file);
1842        zip.start_file::<_, ()>("Song/song.xml", Default::default())
1843            .unwrap();
1844        zip.write_all(
1845            b"<Song><MediaTrack label=\"Lead Vocals\"/><Channel label=\"Main\"/>\
1846              <Insert plugName=\"True-Plugin\"/></Song>",
1847        )
1848        .unwrap();
1849        zip.finish().unwrap();
1850        let result = extract_plugins(tmp.to_str().unwrap());
1851        let names: Vec<&str> = result.iter().map(|p| p.name.as_str()).collect();
1852        assert_eq!(names, vec!["True-Plugin"]);
1853        let _ = fs::remove_file(&tmp);
1854    }
1855
1856    // ── DAWproject tests ──
1857
1858    #[test]
1859    fn test_dawproject_zip_xml() {
1860        use std::io::Write;
1861        let tmp = std::env::temp_dir().join("test_xref.dawproject");
1862        let file = fs::File::create(&tmp).unwrap();
1863        let mut zip = zip::ZipWriter::new(file);
1864        zip.start_file::<_, ()>("project.xml", Default::default())
1865            .unwrap();
1866        zip.write_all(b"<Project><Plugin name=\"Serum\" deviceName=\"Xfer Records\"/><Plugin name=\"Diva\" deviceName=\"u-he\"/></Project>").unwrap();
1867        zip.finish().unwrap();
1868        let result = extract_plugins(tmp.to_str().unwrap());
1869        let names: Vec<&str> = result.iter().map(|p| p.name.as_str()).collect();
1870        assert!(names.contains(&"Serum"), "missing Serum: {:?}", names);
1871        assert!(names.contains(&"Diva"), "missing Diva: {:?}", names);
1872        let _ = fs::remove_file(&tmp);
1873    }
1874
1875    // ── Pro Tools tests ──
1876
1877    #[test]
1878    fn test_protools_binary_extraction() {
1879        // PTX with embedded .aaxplugin paths
1880        let mut data = vec![0u8; 100];
1881        data.extend_from_slice(
1882            b"/Library/Application Support/Avid/Audio/Plug-Ins/EQ III.aaxplugin",
1883        );
1884        data.extend_from_slice(&[0; 50]);
1885        let result = extract_plugins_from_binary(&data);
1886        assert!(!result.is_empty(), "should find AAX plugin");
1887        assert_eq!(result[0].name, "EQ III");
1888        assert_eq!(result[0].plugin_type, "AAX");
1889    }
1890
1891    #[test]
1892    fn test_protools_named_markers() {
1893        let mut data = vec![0u8; 50];
1894        data.extend_from_slice(b"PlugIn Name");
1895        data.push(0);
1896        data.extend_from_slice(b"Channel Strip");
1897        data.extend_from_slice(&[0; 30]);
1898        data.extend_from_slice(b"Insert Name");
1899        data.push(0);
1900        data.extend_from_slice(b"D-Verb");
1901        data.extend_from_slice(&[0; 30]);
1902        let result = extract_named_plugins(&data, b"PlugIn Name");
1903        let result2 = extract_named_plugins(&data, b"Insert Name");
1904        assert!(
1905            !result.is_empty() || !result2.is_empty(),
1906            "should find named plugins"
1907        );
1908    }
1909
1910    // ── Reason tests ──
1911
1912    #[test]
1913    fn test_reason_binary_extraction() {
1914        let mut data = vec![0u8; 100];
1915        data.extend_from_slice(b"C:\\VST\\Massive.dll");
1916        data.extend_from_slice(&[0; 50]);
1917        data.extend_from_slice(b"/Library/Audio/Plug-Ins/VST3/Serum.vst3");
1918        data.extend_from_slice(&[0; 50]);
1919        let tmp = std::env::temp_dir().join("test_xref.reason");
1920        fs::write(&tmp, &data).unwrap();
1921        let result = extract_plugins(tmp.to_str().unwrap());
1922        let names: Vec<&str> = result.iter().map(|p| p.name.as_str()).collect();
1923        assert!(names.contains(&"Massive"), "missing Massive: {:?}", names);
1924        assert!(names.contains(&"Serum"), "missing Serum: {:?}", names);
1925        let _ = fs::remove_file(&tmp);
1926    }
1927
1928    // ── Bitwig tests ──
1929
1930    #[test]
1931    fn test_bitwig_all_plugin_types() {
1932        let tmp = std::env::temp_dir().join("test_xref_bw_types.bwproject");
1933        let mut data = vec![0u8; 100];
1934        data.extend_from_slice(b"/VST3/Serum.vst3");
1935        data.extend_from_slice(&[0; 20]);
1936        data.extend_from_slice(b"C:\\Plugins\\Massive.dll");
1937        data.extend_from_slice(&[0; 20]);
1938        data.extend_from_slice(b"/Components/Kontakt.component");
1939        data.extend_from_slice(&[0; 20]);
1940        data.extend_from_slice(b"/CLAP/Vital.clap");
1941        data.extend_from_slice(&[0; 20]);
1942        fs::write(&tmp, &data).unwrap();
1943        let result = extract_plugins(tmp.to_str().unwrap());
1944        assert!(
1945            result.len() >= 4,
1946            "should find 4+ plugins, got {}",
1947            result.len()
1948        );
1949        let _ = fs::remove_file(&tmp);
1950    }
1951
1952    // ── Cross-format dedup test ──
1953
1954    #[test]
1955    fn test_dedup_across_extraction_methods() {
1956        // Same plugin found via both binary and UTF-16LE should dedup
1957        let mut data = vec![0u8; 50];
1958        data.extend_from_slice(b"C:\\Plugins\\Serum.dll");
1959        data.extend_from_slice(&[0; 30]);
1960        for c in "C:\\Plugins\\Serum.dll".chars() {
1961            data.push(c as u8);
1962            data.push(0);
1963        }
1964        data.extend_from_slice(&[0; 50]);
1965        let tmp = std::env::temp_dir().join("test_xref_dedup.flp");
1966        fs::write(&tmp, &data).unwrap();
1967        let result = extract_plugins(tmp.to_str().unwrap());
1968        let serum_count = result
1969            .iter()
1970            .filter(|p| p.normalized_name == "serum")
1971            .count();
1972        assert_eq!(
1973            serum_count, 1,
1974            "duplicate Serum should be deduped, got {}",
1975            serum_count
1976        );
1977        let _ = fs::remove_file(&tmp);
1978    }
1979
1980    /// `.rpp-bak` must route through the REAPER parser (compound extension).
1981    #[test]
1982    fn test_extract_plugins_reaper_backup_extension() {
1983        let tmp = std::env::temp_dir().join("test_xref_reaper_bak.rpp-bak");
1984        let content = r#"<VST "VST3: Serum (Xfer)" C:\VST3\Serum.vst3"#;
1985        fs::write(&tmp, content).unwrap();
1986        let result = extract_plugins(tmp.to_str().unwrap());
1987        assert!(
1988            result.iter().any(|p| p.name == "Serum"),
1989            "expected Serum from REAPER backup path, got {:?}",
1990            result.iter().map(|p| &p.name).collect::<Vec<_>>()
1991        );
1992        let _ = fs::remove_file(&tmp);
1993    }
1994
1995    #[test]
1996    fn test_extract_plugins_no_extension_returns_empty() {
1997        assert!(extract_plugins("/tmp/no-extension").is_empty());
1998    }
1999
2000    #[test]
2001    fn test_normalize_plugin_name_idempotent_on_clean_name() {
2002        let once = normalize_plugin_name("FabFilter Pro-Q 3");
2003        let twice = normalize_plugin_name(&once);
2004        assert_eq!(once, twice);
2005    }
2006
2007    /// Minimal synthetic fixture per supported extension: exact unique plugin count after
2008    /// `extract_plugins` dedup (normalized name + type). Guards drift in format parsers.
2009    #[test]
2010    fn test_extract_plugins_count_matrix_all_daw_extensions() {
2011        let td = std::env::temp_dir();
2012
2013        // ── Ableton .als (gzip XML, one VST3 block) → 1
2014        let als_path = td.join("xref_matrix_count.als");
2015        {
2016            let xml = r#"<Ableton><Vst3PluginInfo><Name Value="MatrixALS" /><DeviceCreator Value="M" /></Vst3PluginInfo></Ableton>"#;
2017            let f = fs::File::create(&als_path).unwrap();
2018            let mut enc = GzEncoder::new(f, Compression::default());
2019            enc.write_all(xml.as_bytes()).unwrap();
2020            enc.finish().unwrap();
2021        }
2022        assert_eq!(extract_plugins(als_path.to_str().unwrap()).len(), 1);
2023        let _ = fs::remove_file(&als_path);
2024
2025        // ── REAPER .rpp / .rpp-bak → 1 each
2026        let rpp_body = r#"<REAPER_PROJECT
2027  <TRACK
2028    <FXCHAIN
2029      <VST "VST3: MatrixRPP (X)" "{ABCDEF}" 0
2030      >
2031    >
2032  >
2033>"#;
2034        let rpp_path = td.join("xref_matrix_count.rpp");
2035        fs::write(&rpp_path, rpp_body).unwrap();
2036        assert_eq!(extract_plugins(rpp_path.to_str().unwrap()).len(), 1);
2037        let _ = fs::remove_file(&rpp_path);
2038
2039        let rpp_bak_path = td.join("xref_matrix_count.rpp-bak");
2040        fs::write(&rpp_bak_path, rpp_body).unwrap();
2041        assert_eq!(extract_plugins(rpp_bak_path.to_str().unwrap()).len(), 1);
2042        let _ = fs::remove_file(&rpp_bak_path);
2043
2044        // ── Bitwig .bwproject → 1 (single embedded DLL path)
2045        let bw_path = td.join("xref_matrix_count.bwproject");
2046        {
2047            let mut data = b"BtWg0003".to_vec();
2048            data.extend_from_slice(&[0u8; 64]);
2049            data.extend_from_slice(b"C:\\VSTPlugins\\MatrixBW.dll");
2050            fs::write(&bw_path, &data).unwrap();
2051        }
2052        assert_eq!(extract_plugins(bw_path.to_str().unwrap()).len(), 1);
2053        let _ = fs::remove_file(&bw_path);
2054
2055        // ── Studio One .song (ZIP) → 2 distinct plugName
2056        let song_path = td.join("xref_matrix_count.song");
2057        {
2058            use std::io::Write as _;
2059            let file = fs::File::create(&song_path).unwrap();
2060            let mut zip = zip::ZipWriter::new(file);
2061            zip.start_file::<_, ()>("Song/song.xml", Default::default())
2062                .unwrap();
2063            zip.write_all(
2064                b"<Song><Insert plugName=\"MatrixS1A\"/><Insert plugName=\"MatrixS1B\"/></Song>",
2065            )
2066            .unwrap();
2067            zip.finish().unwrap();
2068        }
2069        assert_eq!(extract_plugins(song_path.to_str().unwrap()).len(), 2);
2070        let _ = fs::remove_file(&song_path);
2071
2072        // ── DAWproject → 2 (`<Plugin name=` in project.xml)
2073        let dawproj_path = td.join("xref_matrix_count.dawproject");
2074        {
2075            use std::io::Write as _;
2076            let file = fs::File::create(&dawproj_path).unwrap();
2077            let mut zip = zip::ZipWriter::new(file);
2078            zip.start_file::<_, ()>("project.xml", Default::default())
2079                .unwrap();
2080            zip.write_all(
2081                br#"<Project><Plugin name="MatrixDJP1" deviceName="A"/><Plugin name="MatrixDJP2" deviceName="B"/></Project>"#,
2082            )
2083            .unwrap();
2084            zip.finish().unwrap();
2085        }
2086        assert_eq!(extract_plugins(dawproj_path.to_str().unwrap()).len(), 2);
2087        let _ = fs::remove_file(&dawproj_path);
2088
2089        // ── FL Studio .flp → 1 (single plugin path in binary; pad to reduce UTF-16 false positives)
2090        let flp_path = td.join("xref_matrix_count.flp");
2091        {
2092            let mut data = vec![0u8; 80];
2093            data.extend_from_slice(b"/Library/Audio/Plug-Ins/VST3/MatrixFLP.vst3");
2094            data.resize(4000, 0u8);
2095            fs::write(&flp_path, &data).unwrap();
2096        }
2097        assert_eq!(extract_plugins(flp_path.to_str().unwrap()).len(), 1);
2098        let _ = fs::remove_file(&flp_path);
2099
2100        // ── Logic .logicx (package dir + ProjectData) → 1
2101        let logic_path = td.join("xref_matrix_count.logicx");
2102        {
2103            let _ = fs::remove_dir_all(&logic_path);
2104            fs::create_dir_all(&logic_path).unwrap();
2105            let mut pd = vec![0u8; 16];
2106            pd.extend_from_slice(
2107                b"/Library/Audio/Plug-Ins/Components/MatrixLogicAU.component",
2108            );
2109            fs::write(logic_path.join("ProjectData"), &pd).unwrap();
2110        }
2111        assert_eq!(extract_plugins(logic_path.to_str().unwrap()).len(), 1);
2112        let _ = fs::remove_dir_all(&logic_path);
2113
2114        // ── Cubase .cpr / Nuendo .npr → 1 (single Plugin Name marker; same bytes)
2115        let mut cpr_data = vec![0u8; 8];
2116        cpr_data.extend_from_slice(b"Plugin Name");
2117        cpr_data.push(0);
2118        cpr_data.extend_from_slice(b"MatrixCPR");
2119        let cpr_path = td.join("xref_matrix_count.cpr");
2120        fs::write(&cpr_path, &cpr_data).unwrap();
2121        assert_eq!(extract_plugins(cpr_path.to_str().unwrap()).len(), 1);
2122        let _ = fs::remove_file(&cpr_path);
2123
2124        let npr_path = td.join("xref_matrix_count.npr");
2125        fs::write(&npr_path, &cpr_data).unwrap();
2126        assert_eq!(extract_plugins(npr_path.to_str().unwrap()).len(), 1);
2127        let _ = fs::remove_file(&npr_path);
2128
2129        // ── Pro Tools .ptx / .ptf → 1 each (same binary: one .aaxplugin path)
2130        let mut pt_data = vec![0u8; 16];
2131        pt_data.extend_from_slice(
2132            b"/Library/Application Support/Avid/Audio/Plug-Ins/MatrixPT.aaxplugin",
2133        );
2134        let ptx_path = td.join("xref_matrix_count.ptx");
2135        fs::write(&ptx_path, &pt_data).unwrap();
2136        assert_eq!(extract_plugins(ptx_path.to_str().unwrap()).len(), 1);
2137        let _ = fs::remove_file(&ptx_path);
2138
2139        let ptf_path = td.join("xref_matrix_count.ptf");
2140        fs::write(&ptf_path, &pt_data).unwrap();
2141        assert_eq!(extract_plugins(ptf_path.to_str().unwrap()).len(), 1);
2142        let _ = fs::remove_file(&ptf_path);
2143
2144        // ── Reason .reason → 2 (two distinct plugin paths)
2145        let reason_path = td.join("xref_matrix_count.reason");
2146        {
2147            let mut data = vec![0u8; 12];
2148            data.extend_from_slice(b"C:\\VST\\MatrixReasonA.dll");
2149            data.extend_from_slice(&[0u8; 40]);
2150            data.extend_from_slice(b"/Library/Audio/Plug-Ins/VST3/MatrixReasonB.vst3");
2151            fs::write(&reason_path, &data).unwrap();
2152        }
2153        assert_eq!(extract_plugins(reason_path.to_str().unwrap()).len(), 2);
2154        let _ = fs::remove_file(&reason_path);
2155    }
2156
2157    // ── Real file tests (ignored, run manually) ──
2158
2159    #[test]
2160    #[ignore]
2161    fn test_real_flp() {
2162        let path = "/Users/wizard/mnt/production/MusicProduction/Samples/Producer loops/2021/prototypesamples_RAGE - PROJECT/RAGE PROJECT/_RAGE.flp";
2163        if !std::path::Path::new(path).exists() {
2164            return;
2165        }
2166        let result = extract_plugins(path);
2167        println!("FLP: {} plugins", result.len());
2168        for p in &result {
2169            println!("  {} ({})", p.name, p.plugin_type);
2170        }
2171        assert!(result.len() >= 5, "Real FLP should have 5+ plugins");
2172    }
2173
2174    #[test]
2175    #[ignore]
2176    fn test_real_cubase() {
2177        let path = "/Users/wizard/mnt/production/MusicProduction/Samples/Producer loops/2021/OST Audio - Trance Collection/Collection/Powerful Trance For Spire/Templates/Cubase/0_1 By OST_Audio/0_1 By OST_Audio.cpr";
2178        if !std::path::Path::new(path).exists() {
2179            return;
2180        }
2181        let result = extract_plugins(path);
2182        println!("Cubase: {} plugins", result.len());
2183        for p in &result {
2184            println!("  {} ({})", p.name, p.plugin_type);
2185        }
2186        assert!(result.len() >= 2, "Real Cubase should have 2+ plugins");
2187    }
2188
2189    #[test]
2190    #[ignore]
2191    fn test_real_logic() {
2192        let path = "/Users/wizard/mnt/production/MusicProduction/Samples/mettaglyde/Alex Di Stefano Logic Pro Tech-Trance Template Vol One/Alex Di Stefano Logic Pro Tech-Trance Template Vol One.logicx";
2193        if !std::path::Path::new(path).exists() {
2194            return;
2195        }
2196        let result = extract_plugins(path);
2197        println!("Logic: {} plugins", result.len());
2198        for p in &result {
2199            println!("  {} ({})", p.name, p.plugin_type);
2200        }
2201        assert!(result.len() >= 5, "Real Logic should have 5+ plugins");
2202    }
2203}