app_lib/
history.rs

1use serde::{Deserialize, Serialize};
2use std::fs;
3use std::path::PathBuf;
4use std::sync::Mutex;
5use std::time::{SystemTime, UNIX_EPOCH};
6
7use crate::scanner::PluginInfo;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct ScanSnapshot {
11    pub id: String,
12    pub timestamp: String,
13    #[serde(rename = "pluginCount")]
14    pub plugin_count: usize,
15    pub plugins: Vec<PluginInfo>,
16    pub directories: Vec<String>,
17    #[serde(default)]
18    pub roots: Vec<String>,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct ScanSummary {
23    pub id: String,
24    pub timestamp: String,
25    #[serde(rename = "pluginCount")]
26    pub plugin_count: usize,
27    #[serde(default)]
28    pub roots: Vec<String>,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct ScanHistory {
33    pub scans: Vec<ScanSnapshot>,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct VersionChangedPlugin {
38    #[serde(flatten)]
39    pub plugin: PluginInfo,
40    #[serde(rename = "previousVersion")]
41    pub previous_version: String,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct ScanDiff {
46    #[serde(rename = "oldScan")]
47    pub old_scan: ScanSummary,
48    #[serde(rename = "newScan")]
49    pub new_scan: ScanSummary,
50    pub added: Vec<PluginInfo>,
51    pub removed: Vec<PluginInfo>,
52    #[serde(rename = "versionChanged")]
53    pub version_changed: Vec<VersionChangedPlugin>,
54}
55
56// KVR Cache
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct KvrCacheEntry {
59    #[serde(rename = "kvrUrl")]
60    pub kvr_url: Option<String>,
61    #[serde(rename = "updateUrl")]
62    pub update_url: Option<String>,
63    #[serde(rename = "latestVersion")]
64    pub latest_version: Option<String>,
65    #[serde(rename = "hasUpdate")]
66    pub has_update: bool,
67    pub source: String,
68    pub timestamp: String,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct KvrCacheUpdateEntry {
73    pub key: String,
74    #[serde(rename = "kvrUrl")]
75    pub kvr_url: Option<String>,
76    #[serde(rename = "updateUrl")]
77    pub update_url: Option<String>,
78    #[serde(rename = "latestVersion")]
79    pub latest_version: Option<String>,
80    #[serde(rename = "hasUpdate")]
81    pub has_update: Option<bool>,
82    pub source: Option<String>,
83}
84
85// DAW project types
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct DawProject {
88    pub name: String,
89    pub path: String,
90    pub directory: String,
91    pub format: String,
92    pub daw: String,
93    pub size: u64,
94    #[serde(rename = "sizeFormatted")]
95    pub size_formatted: String,
96    pub modified: String,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct DawScanSnapshot {
101    pub id: String,
102    pub timestamp: String,
103    #[serde(rename = "projectCount")]
104    pub project_count: usize,
105    #[serde(rename = "totalBytes")]
106    pub total_bytes: u64,
107    #[serde(rename = "dawCounts")]
108    pub daw_counts: std::collections::HashMap<String, usize>,
109    pub projects: Vec<DawProject>,
110    #[serde(default)]
111    pub roots: Vec<String>,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct DawScanSummary {
116    pub id: String,
117    pub timestamp: String,
118    #[serde(rename = "projectCount")]
119    pub project_count: usize,
120    #[serde(rename = "totalBytes")]
121    pub total_bytes: u64,
122    #[serde(rename = "dawCounts")]
123    pub daw_counts: std::collections::HashMap<String, usize>,
124    #[serde(default)]
125    pub roots: Vec<String>,
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct DawHistory {
130    pub scans: Vec<DawScanSnapshot>,
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct DawScanDiff {
135    #[serde(rename = "oldScan")]
136    pub old_scan: DawScanSummary,
137    #[serde(rename = "newScan")]
138    pub new_scan: DawScanSummary,
139    pub added: Vec<DawProject>,
140    pub removed: Vec<DawProject>,
141}
142
143// Audio scan types
144#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct AudioSample {
146    pub name: String,
147    pub path: String,
148    pub directory: String,
149    pub format: String,
150    pub size: u64,
151    #[serde(rename = "sizeFormatted")]
152    pub size_formatted: String,
153    pub modified: String,
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub duration: Option<f64>,
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub channels: Option<u16>,
158    #[serde(
159        default,
160        rename = "sampleRate",
161        skip_serializing_if = "Option::is_none"
162    )]
163    pub sample_rate: Option<u32>,
164    #[serde(
165        default,
166        rename = "bitsPerSample",
167        skip_serializing_if = "Option::is_none"
168    )]
169    pub bits_per_sample: Option<u16>,
170}
171
172#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct AudioScanSnapshot {
174    pub id: String,
175    pub timestamp: String,
176    #[serde(rename = "sampleCount")]
177    pub sample_count: usize,
178    #[serde(rename = "totalBytes")]
179    pub total_bytes: u64,
180    #[serde(rename = "formatCounts")]
181    pub format_counts: std::collections::HashMap<String, usize>,
182    pub samples: Vec<AudioSample>,
183    #[serde(default)]
184    pub roots: Vec<String>,
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct AudioScanSummary {
189    pub id: String,
190    pub timestamp: String,
191    #[serde(rename = "sampleCount")]
192    pub sample_count: usize,
193    #[serde(rename = "totalBytes")]
194    pub total_bytes: u64,
195    #[serde(rename = "formatCounts")]
196    pub format_counts: std::collections::HashMap<String, usize>,
197    #[serde(default)]
198    pub roots: Vec<String>,
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize)]
202pub struct AudioHistory {
203    pub scans: Vec<AudioScanSnapshot>,
204}
205
206#[derive(Debug, Clone, Serialize, Deserialize)]
207pub struct AudioScanDiff {
208    #[serde(rename = "oldScan")]
209    pub old_scan: AudioScanSummary,
210    #[serde(rename = "newScan")]
211    pub new_scan: AudioScanSummary,
212    pub added: Vec<AudioSample>,
213    pub removed: Vec<AudioSample>,
214}
215
216// Preset scan types
217#[derive(Debug, Clone, Serialize, Deserialize)]
218pub struct PresetFile {
219    pub name: String,
220    pub path: String,
221    pub directory: String,
222    pub format: String,
223    pub size: u64,
224    #[serde(rename = "sizeFormatted")]
225    pub size_formatted: String,
226    pub modified: String,
227}
228
229#[derive(Debug, Clone, Serialize, Deserialize)]
230pub struct PresetScanSnapshot {
231    pub id: String,
232    pub timestamp: String,
233    #[serde(rename = "presetCount")]
234    pub preset_count: usize,
235    #[serde(rename = "totalBytes")]
236    pub total_bytes: u64,
237    #[serde(rename = "formatCounts")]
238    pub format_counts: std::collections::HashMap<String, usize>,
239    pub presets: Vec<PresetFile>,
240    #[serde(default)]
241    pub roots: Vec<String>,
242}
243
244#[derive(Debug, Clone, Serialize, Deserialize)]
245pub struct PresetScanSummary {
246    pub id: String,
247    pub timestamp: String,
248    #[serde(rename = "presetCount")]
249    pub preset_count: usize,
250    #[serde(rename = "totalBytes")]
251    pub total_bytes: u64,
252    #[serde(rename = "formatCounts")]
253    pub format_counts: std::collections::HashMap<String, usize>,
254    #[serde(default)]
255    pub roots: Vec<String>,
256}
257
258#[derive(Debug, Clone, Serialize, Deserialize)]
259pub struct PresetHistory {
260    pub scans: Vec<PresetScanSnapshot>,
261}
262
263#[derive(Debug, Clone, Serialize, Deserialize)]
264pub struct PresetScanDiff {
265    #[serde(rename = "oldScan")]
266    pub old_scan: PresetScanSummary,
267    #[serde(rename = "newScan")]
268    pub new_scan: PresetScanSummary,
269    pub added: Vec<PresetFile>,
270    pub removed: Vec<PresetFile>,
271}
272
273// MIDI scan types
274#[derive(Debug, Clone, Serialize, Deserialize)]
275pub struct MidiFile {
276    pub name: String,
277    pub path: String,
278    pub directory: String,
279    pub format: String, // "MID" or "MIDI"
280    pub size: u64,
281    #[serde(rename = "sizeFormatted")]
282    pub size_formatted: String,
283    pub modified: String,
284}
285
286#[derive(Debug, Clone, Serialize, Deserialize)]
287pub struct MidiScanSnapshot {
288    pub id: String,
289    pub timestamp: String,
290    #[serde(rename = "midiCount")]
291    pub midi_count: usize,
292    #[serde(rename = "totalBytes")]
293    pub total_bytes: u64,
294    #[serde(rename = "formatCounts")]
295    pub format_counts: std::collections::HashMap<String, usize>,
296    #[serde(rename = "midiFiles")]
297    pub midi_files: Vec<MidiFile>,
298    #[serde(default)]
299    pub roots: Vec<String>,
300}
301
302#[derive(Debug, Clone, Serialize, Deserialize)]
303pub struct MidiScanSummary {
304    pub id: String,
305    pub timestamp: String,
306    #[serde(rename = "midiCount")]
307    pub midi_count: usize,
308    #[serde(rename = "totalBytes")]
309    pub total_bytes: u64,
310    #[serde(rename = "formatCounts")]
311    pub format_counts: std::collections::HashMap<String, usize>,
312    #[serde(default)]
313    pub roots: Vec<String>,
314}
315
316#[derive(Debug, Clone, Serialize, Deserialize)]
317pub struct MidiScanDiff {
318    #[serde(rename = "oldScan")]
319    pub old_scan: MidiScanSummary,
320    #[serde(rename = "newScan")]
321    pub new_scan: MidiScanSummary,
322    pub added: Vec<MidiFile>,
323    pub removed: Vec<MidiFile>,
324}
325
326// PDF scan types
327#[derive(Debug, Clone, Serialize, Deserialize)]
328pub struct PdfFile {
329    pub name: String,
330    pub path: String,
331    pub directory: String,
332    pub size: u64,
333    #[serde(rename = "sizeFormatted")]
334    pub size_formatted: String,
335    pub modified: String,
336}
337
338#[derive(Debug, Clone, Serialize, Deserialize)]
339pub struct PdfScanSnapshot {
340    pub id: String,
341    pub timestamp: String,
342    #[serde(rename = "pdfCount")]
343    pub pdf_count: usize,
344    #[serde(rename = "totalBytes")]
345    pub total_bytes: u64,
346    pub pdfs: Vec<PdfFile>,
347    #[serde(default)]
348    pub roots: Vec<String>,
349}
350
351#[derive(Debug, Clone, Serialize, Deserialize)]
352pub struct PdfScanSummary {
353    pub id: String,
354    pub timestamp: String,
355    #[serde(rename = "pdfCount")]
356    pub pdf_count: usize,
357    #[serde(rename = "totalBytes")]
358    pub total_bytes: u64,
359    #[serde(default)]
360    pub roots: Vec<String>,
361}
362
363#[derive(Debug, Clone, Serialize, Deserialize)]
364pub struct PdfScanDiff {
365    #[serde(rename = "oldScan")]
366    pub old_scan: PdfScanSummary,
367    #[serde(rename = "newScan")]
368    pub new_scan: PdfScanSummary,
369    pub added: Vec<PdfFile>,
370    pub removed: Vec<PdfFile>,
371}
372
373#[cfg(test)]
374thread_local! {
375    static TEST_DATA_DIR: std::cell::RefCell<Option<PathBuf>> = const { std::cell::RefCell::new(None) };
376}
377
378/// Bundle / data subdir — must match `identifier` in `tauri.conf.json` and Tauri `app_data_dir()`.
379const APP_DATA_IDENTIFIER: &str = "com.menketechnologies.audio-haxor";
380
381/// Process-wide test override so [`get_data_dir`] matches on **all** threads (spawned threads
382/// do not inherit `thread_local` storage — required for `init_global` stress tests and parallel
383/// `cargo test` workers that share one temp directory).
384#[cfg(test)]
385static TEST_DATA_DIR_GLOBAL: Mutex<Option<PathBuf>> = Mutex::new(None);
386
387/// When `dirs::data_dir()` is unavailable, resolve under the home directory — **never** use the
388/// process working directory. A cwd-based path changes with how the app is launched (Finder vs
389/// Terminal vs IDE), so prefs and DB would appear to "reset" every run.
390fn app_data_dir_from_home(home: &std::path::Path) -> PathBuf {
391    let base = if cfg!(target_os = "macos") {
392        home.join("Library/Application Support")
393    } else if cfg!(target_os = "linux") {
394        home.join(".local/share")
395    } else if cfg!(target_os = "windows") {
396        home.join("AppData/Roaming")
397    } else {
398        home.join(".local/share")
399    };
400    base.join(APP_DATA_IDENTIFIER)
401}
402
403pub fn get_data_dir() -> PathBuf {
404    #[cfg(test)]
405    {
406        if let Ok(g) = TEST_DATA_DIR_GLOBAL.lock() {
407            if let Some(ref dir) = *g {
408                return dir.clone();
409            }
410        }
411        if let Some(dir) = TEST_DATA_DIR.with(|d| d.borrow().clone()) {
412            return dir;
413        }
414    }
415    if let Some(base) = dirs::data_dir() {
416        return base.join(APP_DATA_IDENTIFIER);
417    }
418    if let Some(home) = dirs::home_dir() {
419        return app_data_dir_from_home(&home);
420    }
421    // Last resort: temp dir is stable for the lifetime of the process; still better than cwd.
422    std::env::temp_dir().join(format!("audio-haxor-{APP_DATA_IDENTIFIER}"))
423}
424
425/// Redirect [`get_data_dir`] for unit tests (e.g. `lib.rs` log tests). Clear when done.
426#[cfg(test)]
427pub fn set_test_data_dir_path(path: PathBuf) {
428    if let Ok(mut g) = TEST_DATA_DIR_GLOBAL.lock() {
429        *g = Some(path.clone());
430    }
431    TEST_DATA_DIR.with(|d| *d.borrow_mut() = Some(path));
432}
433
434#[cfg(test)]
435pub fn clear_test_data_dir_path() {
436    if let Ok(mut g) = TEST_DATA_DIR_GLOBAL.lock() {
437        *g = None;
438    }
439    TEST_DATA_DIR.with(|d| *d.borrow_mut() = None);
440}
441
442/// Creates `get_data_dir()` if needed; safe to call before writing files there.
443pub fn ensure_data_dir() -> PathBuf {
444    let dir = get_data_dir();
445    let _ = fs::create_dir_all(&dir);
446    dir
447}
448
449fn history_file() -> PathBuf {
450    ensure_data_dir().join("scan-history.json")
451}
452
453fn kvr_cache_file() -> PathBuf {
454    ensure_data_dir().join("kvr-cache.json")
455}
456
457fn audio_history_file() -> PathBuf {
458    ensure_data_dir().join("audio-scan-history.json")
459}
460
461fn daw_history_file() -> PathBuf {
462    ensure_data_dir().join("daw-scan-history.json")
463}
464
465fn preferences_file() -> PathBuf {
466    ensure_data_dir().join("preferences.toml")
467}
468
469fn legacy_preferences_file() -> PathBuf {
470    ensure_data_dir().join("preferences.json")
471}
472
473pub fn get_preferences_path() -> PathBuf {
474    preferences_file()
475}
476
477pub type PrefsMap = serde_json::Map<String, serde_json::Value>;
478
479/// Avoid re-reading preferences.toml on every hot path (e.g. `get_process_stats` once per second).
480/// Cache entries are keyed by the resolved preferences path so parallel tests (each with a
481/// thread-local temp data dir) and the main app never share a `PrefsMap` across different files.
482static PREF_CACHE: Mutex<Option<(u64, PathBuf, PrefsMap)>> = Mutex::new(None);
483const PREF_CACHE_TTL_MS: u64 = 2000;
484/// Serializes read-modify-write preference updates so concurrent `prefs_set` / `prefs_remove`
485/// calls (e.g. color scheme + removing `customSchemeVars`) cannot clobber each other on disk.
486static PREF_RMW_LOCK: Mutex<()> = Mutex::new(());
487
488fn prefs_cache_now_ms() -> u64 {
489    SystemTime::now()
490        .duration_since(UNIX_EPOCH)
491        .unwrap_or_default()
492        .as_millis() as u64
493}
494
495fn invalidate_prefs_cache() {
496    if let Ok(mut g) = PREF_CACHE.lock() {
497        *g = None;
498    }
499}
500
501// Maps flat pref keys to TOML sections for organized file layout.
502// Keys not listed here go under [general].
503// Format: (section_name, &[(flat_key, toml_key)])
504const SECTION_MAP: &[(&str, &[(&str, &str)])] = &[
505    ("window", &[("window", "window")]),
506    (
507        "appearance",
508        &[
509            ("theme", "theme"),
510            ("colorScheme", "colorScheme"),
511            ("crtEffects", "crtEffects"),
512            ("tooltipHoverDelayMs", "tooltipHoverDelayMs"),
513        ],
514    ),
515    (
516        "scanning",
517        &[
518            ("autoScan", "autoScan"),
519            ("autoUpdate", "autoUpdate"),
520            ("singleClickPlay", "singleClickPlay"),
521            ("autoPlaySampleOnSelect", "autoPlaySampleOnSelect"),
522            ("defaultTypeFilter", "defaultTypeFilter"),
523            ("customDirs", "customDirs"),
524            ("audioScanDirs", "audioScanDirs"),
525            ("dawScanDirs", "dawScanDirs"),
526            ("presetScanDirs", "presetScanDirs"),
527            ("includeAbletonBackups", "includeAbletonBackups"),
528            ("incrementalDirectoryScan", "incrementalDirectoryScan"),
529        ],
530    ),
531    (
532        "sorting",
533        &[
534            ("pluginSort", "pluginSort"),
535            ("audioSort", "audioSort"),
536            ("dawSort", "dawSort"),
537            ("presetSort", "presetSort"),
538            ("midiSort", "midiSort"),
539            ("pdfSort", "pdfSort"),
540        ],
541    ),
542    (
543        "performance",
544        &[
545            ("pageSize", "pageSize"),
546            ("flushInterval", "flushInterval"),
547            ("threadMultiplier", "threadMultiplier"),
548            ("channelBuffer", "channelBuffer"),
549            ("batchSize", "batchSize"),
550            ("sqliteReadPoolExtra", "sqliteReadPoolExtra"),
551            ("pruneOldScans", "pruneOldScans"),
552            ("pruneOldScansKeep", "pruneOldScansKeep"),
553        ],
554    ),
555    ("logging", &[("logVerbosity", "verbosity")]),
556    ("player", &[("playerDock", "dock")]),
557    ("tabs", &[("tabOrder", "order")]),
558    (
559        "customScheme",
560        &[
561            ("customSchemeVars", "vars"),
562            ("customSchemePresets", "presets"),
563        ],
564    ),
565    ("data", &[("columnWidths", "widths")]),
566    ("favorites", &[("favorites", "items")]),
567    ("notes", &[("itemNotes", "itemNotes")]),
568    ("history", &[("recentlyPlayed", "recentlyPlayed")]),
569];
570
571fn default_config() -> PrefsMap {
572    let toml_str = include_str!("../../config.default.toml");
573    toml_to_flat(toml_str)
574}
575
576/// Build a reverse lookup: (section, toml_key) → flat_key
577fn toml_key_to_flat(section: &str, toml_key: &str) -> Option<String> {
578    for (sec, keys) in SECTION_MAP {
579        if *sec == section {
580            for (flat, tk) in *keys {
581                if *tk == toml_key {
582                    return Some(flat.to_string());
583                }
584            }
585        }
586    }
587    None
588}
589
590/// If a value is a string that looks like JSON array/object, parse it to native.
591/// Handles migration from old format where structured data was JSON-stringified.
592fn migrate_json_string(val: serde_json::Value) -> serde_json::Value {
593    if let serde_json::Value::String(s) = &val {
594        let trimmed = s.trim();
595        if (trimmed.starts_with('[') && trimmed.ends_with(']'))
596            || (trimmed.starts_with('{') && trimmed.ends_with('}'))
597        {
598            if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(trimmed) {
599                return parsed;
600            }
601        }
602    }
603    val
604}
605
606/// Parse a TOML string into a flat PrefsMap.
607/// Top-level sections become either a nested JSON value (for "window")
608/// or their inner keys are promoted to flat top-level keys using SECTION_MAP.
609fn toml_to_flat(toml_str: &str) -> PrefsMap {
610    let table: toml::Table = match toml::from_str(toml_str) {
611        Ok(t) => t,
612        Err(_) => return PrefsMap::new(),
613    };
614    let mut map = PrefsMap::new();
615    for (section, val) in &table {
616        if let toml::Value::Table(inner) = val {
617            if section == "window" {
618                map.insert(
619                    section.clone(),
620                    toml_value_to_json(&toml::Value::Table(inner.clone())),
621                );
622            } else {
623                for (toml_key, v) in inner {
624                    let flat_key =
625                        toml_key_to_flat(section, toml_key).unwrap_or_else(|| toml_key.clone());
626                    let json_val = migrate_json_string(toml_value_to_json(v));
627                    map.insert(flat_key, json_val);
628                }
629            }
630        } else {
631            map.insert(section.clone(), toml_value_to_json(val));
632        }
633    }
634    map
635}
636
637fn toml_value_to_json(val: &toml::Value) -> serde_json::Value {
638    match val {
639        toml::Value::String(s) => serde_json::Value::String(s.clone()),
640        toml::Value::Integer(i) => serde_json::json!(*i),
641        toml::Value::Float(f) => serde_json::json!(*f),
642        toml::Value::Boolean(b) => serde_json::Value::Bool(*b),
643        toml::Value::Array(arr) => {
644            serde_json::Value::Array(arr.iter().map(toml_value_to_json).collect())
645        }
646        toml::Value::Table(t) => {
647            let mut m = PrefsMap::new();
648            for (k, v) in t {
649                m.insert(k.clone(), toml_value_to_json(v));
650            }
651            serde_json::Value::Object(m)
652        }
653        toml::Value::Datetime(d) => serde_json::Value::String(d.to_string()),
654    }
655}
656
657fn json_to_toml_value(val: &serde_json::Value) -> toml::Value {
658    match val {
659        serde_json::Value::String(s) => toml::Value::String(s.clone()),
660        serde_json::Value::Number(n) => {
661            if let Some(i) = n.as_i64() {
662                toml::Value::Integer(i)
663            } else {
664                toml::Value::Float(n.as_f64().unwrap_or(0.0))
665            }
666        }
667        serde_json::Value::Bool(b) => toml::Value::Boolean(*b),
668        serde_json::Value::Array(arr) => {
669            toml::Value::Array(arr.iter().map(json_to_toml_value).collect())
670        }
671        serde_json::Value::Object(m) => {
672            let mut t = toml::Table::new();
673            for (k, v) in m {
674                t.insert(k.clone(), json_to_toml_value(v));
675            }
676            toml::Value::Table(t)
677        }
678        serde_json::Value::Null => toml::Value::String(String::new()),
679    }
680}
681
682/// Convert a flat PrefsMap into sectioned TOML string.
683fn flat_to_toml(prefs: &PrefsMap) -> String {
684    let mut root = toml::Table::new();
685
686    // Collect keys into sections preserving SECTION_MAP order
687    for (section, key_pairs) in SECTION_MAP {
688        let mut sec_table = toml::Table::new();
689        if *section == "window" {
690            if let Some(serde_json::Value::Object(m)) = prefs.get("window") {
691                for (k, v) in m {
692                    sec_table.insert(k.clone(), json_to_toml_value(v));
693                }
694            }
695        } else {
696            for (flat_key, toml_key) in *key_pairs {
697                if let Some(val) = prefs.get(*flat_key) {
698                    sec_table.insert(toml_key.to_string(), json_to_toml_value(val));
699                }
700            }
701        }
702        if !sec_table.is_empty() {
703            root.insert(section.to_string(), toml::Value::Table(sec_table));
704        }
705    }
706
707    // Any remaining keys not in SECTION_MAP go under [general]
708    let all_flat_keys: Vec<&str> = SECTION_MAP
709        .iter()
710        .flat_map(|(_, pairs)| pairs.iter().map(|(flat, _)| *flat))
711        .collect();
712    let mut general = toml::Table::new();
713    for (k, v) in prefs {
714        if k == "window" || all_flat_keys.contains(&k.as_str()) {
715            continue;
716        }
717        general.insert(k.clone(), json_to_toml_value(v));
718    }
719    if !general.is_empty() {
720        root.insert("general".to_string(), toml::Value::Table(general));
721    }
722
723    toml::to_string_pretty(&root).unwrap_or_default()
724}
725
726fn load_preferences_from_disk() -> PrefsMap {
727    let path = preferences_file();
728
729    // Migrate from legacy JSON if TOML doesn't exist yet
730    if !path.exists() {
731        let legacy = legacy_preferences_file();
732        if legacy.exists() {
733            if let Ok(data) = fs::read_to_string(&legacy) {
734                if let Ok(serde_json::Value::Object(user)) = serde_json::from_str(&data) {
735                    let defaults = default_config();
736                    let merged = merge_prefs(&defaults, &user);
737                    save_preferences(&merged);
738                    let _ = fs::remove_file(&legacy);
739                    return merged;
740                }
741            }
742        }
743    }
744
745    if path.exists() {
746        match fs::read_to_string(&path) {
747            Ok(data) => {
748                let user = toml_to_flat(&data);
749                let defaults = default_config();
750                return merge_prefs(&defaults, &user);
751            }
752            Err(e) => {
753                crate::append_log(format!(
754                    "preferences: read failed {} — using defaults in memory without overwriting ({e})",
755                    path.display()
756                ));
757                let defaults = default_config();
758                return merge_prefs(&defaults, &PrefsMap::new());
759            }
760        }
761    }
762    let defaults = default_config();
763    save_preferences(&defaults);
764    defaults
765}
766
767pub fn load_preferences() -> PrefsMap {
768    let now = prefs_cache_now_ms();
769    let path = preferences_file();
770    {
771        if let Ok(guard) = PREF_CACHE.lock() {
772            if let Some((t, cached_path, p)) = guard.as_ref() {
773                if now.saturating_sub(*t) < PREF_CACHE_TTL_MS && *cached_path == path {
774                    return p.clone();
775                }
776            }
777        }
778    }
779    let loaded = load_preferences_from_disk();
780    if let Ok(mut guard) = PREF_CACHE.lock() {
781        *guard = Some((now, path, loaded.clone()));
782    }
783    loaded
784}
785
786fn merge_prefs(defaults: &PrefsMap, user: &PrefsMap) -> PrefsMap {
787    let mut merged = PrefsMap::new();
788    for (k, v) in defaults {
789        merged.insert(k.clone(), user.get(k).cloned().unwrap_or_else(|| v.clone()));
790    }
791    for (k, v) in user {
792        if !merged.contains_key(k) {
793            merged.insert(k.clone(), v.clone());
794        }
795    }
796    merged
797}
798
799pub fn save_preferences(prefs: &PrefsMap) {
800    let path = preferences_file();
801    let toml_str = flat_to_toml(prefs);
802    let _ = fs::write(&path, toml_str);
803    invalidate_prefs_cache();
804}
805
806pub fn set_preference(key: &str, value: serde_json::Value) {
807    let _guard = PREF_RMW_LOCK.lock().unwrap_or_else(|e| e.into_inner());
808    let mut prefs = load_preferences();
809    prefs.insert(key.to_string(), value);
810    save_preferences(&prefs);
811}
812
813pub fn get_preference(key: &str) -> Option<serde_json::Value> {
814    let prefs = load_preferences();
815    prefs.get(key).cloned()
816}
817
818pub fn remove_preference(key: &str) {
819    let _guard = PREF_RMW_LOCK.lock().unwrap_or_else(|e| e.into_inner());
820    let mut prefs = load_preferences();
821    prefs.remove(key);
822    save_preferences(&prefs);
823}
824
825pub fn gen_id() -> String {
826    let ts = std::time::SystemTime::now()
827        .duration_since(std::time::UNIX_EPOCH)
828        .unwrap_or_default()
829        .as_millis();
830    let rand_part: u32 = rand::random();
831    format!(
832        "{}{}",
833        radix_string(ts as u64, 36),
834        radix_string(rand_part as u64, 36)
835    )
836}
837
838pub fn radix_string(mut n: u64, base: u64) -> String {
839    if n == 0 {
840        return "0".into();
841    }
842    let chars: Vec<char> = "0123456789abcdefghijklmnopqrstuvwxyz".chars().collect();
843    let mut result = Vec::new();
844    while n > 0 {
845        result.push(chars[(n % base) as usize]);
846        n /= base;
847    }
848    result.reverse();
849    result.into_iter().collect()
850}
851
852pub fn now_iso() -> String {
853    chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
854}
855
856// ── Plugin scan history ──
857
858fn load_history() -> ScanHistory {
859    let path = history_file();
860    if path.exists() {
861        if let Ok(data) = fs::read_to_string(&path) {
862            if let Ok(h) = serde_json::from_str(&data) {
863                return h;
864            }
865        }
866    }
867    ScanHistory { scans: vec![] }
868}
869
870fn save_history(history: &ScanHistory) {
871    let path = history_file();
872    if let Ok(data) = serde_json::to_string_pretty(history) {
873        let _ = fs::write(path, data);
874    }
875}
876
877/// Build a ScanSnapshot without file I/O (for SQLite path).
878pub fn build_plugin_snapshot(
879    plugins: &[PluginInfo],
880    directories: &[String],
881    roots: &[String],
882) -> ScanSnapshot {
883    ScanSnapshot {
884        id: gen_id(),
885        timestamp: now_iso(),
886        plugin_count: plugins.len(),
887        plugins: plugins.to_vec(),
888        directories: directories.to_vec(),
889        roots: roots.to_vec(),
890    }
891}
892
893pub fn save_scan(plugins: &[PluginInfo], directories: &[String], roots: &[String]) -> ScanSnapshot {
894    let mut history = load_history();
895    let snapshot = ScanSnapshot {
896        id: gen_id(),
897        timestamp: now_iso(),
898        plugin_count: plugins.len(),
899        plugins: plugins.to_vec(),
900        directories: directories.to_vec(),
901        roots: roots.to_vec(),
902    };
903    history.scans.insert(0, snapshot.clone());
904    if history.scans.len() > 50 {
905        history.scans.truncate(50);
906    }
907    save_history(&history);
908    snapshot
909}
910
911pub fn get_scans() -> Vec<ScanSummary> {
912    let history = load_history();
913    history
914        .scans
915        .iter()
916        .map(|s| ScanSummary {
917            id: s.id.clone(),
918            timestamp: s.timestamp.clone(),
919            plugin_count: s.plugin_count,
920            roots: s.roots.clone(),
921        })
922        .collect()
923}
924
925pub fn get_scan_detail(id: &str) -> Option<ScanSnapshot> {
926    let history = load_history();
927    history.scans.into_iter().find(|s| s.id == id)
928}
929
930pub fn delete_scan(id: &str) {
931    let mut history = load_history();
932    history.scans.retain(|s| s.id != id);
933    save_history(&history);
934}
935
936pub fn clear_history() {
937    save_history(&ScanHistory { scans: vec![] });
938}
939
940pub fn diff_scans(old_id: &str, new_id: &str) -> Option<ScanDiff> {
941    let history = load_history();
942    let old_scan = history.scans.iter().find(|s| s.id == old_id)?;
943    let new_scan = history.scans.iter().find(|s| s.id == new_id)?;
944
945    let old_paths: std::collections::HashSet<&str> =
946        old_scan.plugins.iter().map(|p| p.path.as_str()).collect();
947    let new_paths: std::collections::HashSet<&str> =
948        new_scan.plugins.iter().map(|p| p.path.as_str()).collect();
949
950    let old_by_path: std::collections::HashMap<&str, &PluginInfo> = old_scan
951        .plugins
952        .iter()
953        .map(|p| (p.path.as_str(), p))
954        .collect();
955
956    let added: Vec<PluginInfo> = new_scan
957        .plugins
958        .iter()
959        .filter(|p| !old_paths.contains(p.path.as_str()))
960        .cloned()
961        .collect();
962
963    let removed: Vec<PluginInfo> = old_scan
964        .plugins
965        .iter()
966        .filter(|p| !new_paths.contains(p.path.as_str()))
967        .cloned()
968        .collect();
969
970    let version_changed: Vec<VersionChangedPlugin> = new_scan
971        .plugins
972        .iter()
973        .filter_map(|p| {
974            let old = old_by_path.get(p.path.as_str())?;
975            if old.version != p.version && p.version != "Unknown" && old.version != "Unknown" {
976                Some(VersionChangedPlugin {
977                    plugin: p.clone(),
978                    previous_version: old.version.clone(),
979                })
980            } else {
981                None
982            }
983        })
984        .collect();
985
986    Some(ScanDiff {
987        old_scan: ScanSummary {
988            id: old_scan.id.clone(),
989            timestamp: old_scan.timestamp.clone(),
990            plugin_count: old_scan.plugin_count,
991            roots: old_scan.roots.clone(),
992        },
993        new_scan: ScanSummary {
994            id: new_scan.id.clone(),
995            timestamp: new_scan.timestamp.clone(),
996            plugin_count: new_scan.plugin_count,
997            roots: new_scan.roots.clone(),
998        },
999        added,
1000        removed,
1001        version_changed,
1002    })
1003}
1004
1005pub fn get_latest_scan() -> Option<ScanSnapshot> {
1006    let history = load_history();
1007    history.scans.into_iter().next()
1008}
1009
1010/// Compute diff between two plugin snapshots (no file I/O).
1011pub fn compute_plugin_diff(old_scan: &ScanSnapshot, new_scan: &ScanSnapshot) -> ScanDiff {
1012    let old_paths: std::collections::HashSet<&str> =
1013        old_scan.plugins.iter().map(|p| p.path.as_str()).collect();
1014    let new_paths: std::collections::HashSet<&str> =
1015        new_scan.plugins.iter().map(|p| p.path.as_str()).collect();
1016    let old_by_path: std::collections::HashMap<&str, &PluginInfo> = old_scan
1017        .plugins
1018        .iter()
1019        .map(|p| (p.path.as_str(), p))
1020        .collect();
1021    let added: Vec<PluginInfo> = new_scan
1022        .plugins
1023        .iter()
1024        .filter(|p| !old_paths.contains(p.path.as_str()))
1025        .cloned()
1026        .collect();
1027    let removed: Vec<PluginInfo> = old_scan
1028        .plugins
1029        .iter()
1030        .filter(|p| !new_paths.contains(p.path.as_str()))
1031        .cloned()
1032        .collect();
1033    let version_changed: Vec<VersionChangedPlugin> = new_scan
1034        .plugins
1035        .iter()
1036        .filter_map(|p| {
1037            let old = old_by_path.get(p.path.as_str())?;
1038            if old.version != p.version && p.version != "Unknown" && old.version != "Unknown" {
1039                Some(VersionChangedPlugin {
1040                    plugin: p.clone(),
1041                    previous_version: old.version.clone(),
1042                })
1043            } else {
1044                None
1045            }
1046        })
1047        .collect();
1048    ScanDiff {
1049        old_scan: ScanSummary {
1050            id: old_scan.id.clone(),
1051            timestamp: old_scan.timestamp.clone(),
1052            plugin_count: old_scan.plugin_count,
1053            roots: old_scan.roots.clone(),
1054        },
1055        new_scan: ScanSummary {
1056            id: new_scan.id.clone(),
1057            timestamp: new_scan.timestamp.clone(),
1058            plugin_count: new_scan.plugin_count,
1059            roots: new_scan.roots.clone(),
1060        },
1061        added,
1062        removed,
1063        version_changed,
1064    }
1065}
1066
1067// ── KVR Cache ──
1068
1069pub fn load_kvr_cache() -> std::collections::HashMap<String, KvrCacheEntry> {
1070    let path = kvr_cache_file();
1071    if path.exists() {
1072        if let Ok(data) = fs::read_to_string(&path) {
1073            if let Ok(cache) = serde_json::from_str(&data) {
1074                return cache;
1075            }
1076        }
1077    }
1078    std::collections::HashMap::new()
1079}
1080
1081fn save_kvr_cache(cache: &std::collections::HashMap<String, KvrCacheEntry>) {
1082    let path = kvr_cache_file();
1083    if let Ok(data) = serde_json::to_string_pretty(cache) {
1084        let _ = fs::write(path, data);
1085    }
1086}
1087
1088pub fn update_kvr_cache(entries: &[KvrCacheUpdateEntry]) {
1089    let mut cache = load_kvr_cache();
1090    for entry in entries {
1091        cache.insert(
1092            entry.key.clone(),
1093            KvrCacheEntry {
1094                kvr_url: entry.kvr_url.clone(),
1095                update_url: entry.update_url.clone(),
1096                latest_version: entry.latest_version.clone(),
1097                has_update: entry.has_update.unwrap_or(false),
1098                source: entry.source.clone().unwrap_or_else(|| "kvr".into()),
1099                timestamp: now_iso(),
1100            },
1101        );
1102    }
1103    save_kvr_cache(&cache);
1104}
1105
1106// ── Audio scan history ──
1107
1108fn load_audio_history() -> AudioHistory {
1109    let path = audio_history_file();
1110    if path.exists() {
1111        if let Ok(data) = fs::read_to_string(&path) {
1112            if let Ok(h) = serde_json::from_str(&data) {
1113                return h;
1114            }
1115        }
1116    }
1117    AudioHistory { scans: vec![] }
1118}
1119
1120fn save_audio_history(history: &AudioHistory) {
1121    let path = audio_history_file();
1122    if let Ok(data) = serde_json::to_string_pretty(history) {
1123        let _ = fs::write(path, data);
1124    }
1125}
1126
1127/// Build an AudioScanSnapshot without file I/O (for SQLite path).
1128pub fn build_audio_snapshot(samples: &[AudioSample], roots: &[String]) -> AudioScanSnapshot {
1129    let mut format_counts = std::collections::HashMap::new();
1130    let mut total_bytes = 0u64;
1131    for s in samples {
1132        *format_counts.entry(s.format.clone()).or_insert(0) += 1;
1133        total_bytes += s.size;
1134    }
1135    AudioScanSnapshot {
1136        id: gen_id(),
1137        timestamp: now_iso(),
1138        sample_count: samples.len(),
1139        total_bytes,
1140        format_counts,
1141        samples: samples.to_vec(),
1142        roots: roots.to_vec(),
1143    }
1144}
1145
1146/// Compute diff between two audio snapshots (no file I/O).
1147pub fn compute_audio_diff(
1148    old_scan: &AudioScanSnapshot,
1149    new_scan: &AudioScanSnapshot,
1150) -> AudioScanDiff {
1151    let old_paths: std::collections::HashSet<&str> =
1152        old_scan.samples.iter().map(|s| s.path.as_str()).collect();
1153    let new_paths: std::collections::HashSet<&str> =
1154        new_scan.samples.iter().map(|s| s.path.as_str()).collect();
1155    let added: Vec<AudioSample> = new_scan
1156        .samples
1157        .iter()
1158        .filter(|s| !old_paths.contains(s.path.as_str()))
1159        .cloned()
1160        .collect();
1161    let removed: Vec<AudioSample> = old_scan
1162        .samples
1163        .iter()
1164        .filter(|s| !new_paths.contains(s.path.as_str()))
1165        .cloned()
1166        .collect();
1167    AudioScanDiff {
1168        old_scan: AudioScanSummary {
1169            id: old_scan.id.clone(),
1170            timestamp: old_scan.timestamp.clone(),
1171            sample_count: old_scan.sample_count,
1172            total_bytes: old_scan.total_bytes,
1173            format_counts: old_scan.format_counts.clone(),
1174            roots: old_scan.roots.clone(),
1175        },
1176        new_scan: AudioScanSummary {
1177            id: new_scan.id.clone(),
1178            timestamp: new_scan.timestamp.clone(),
1179            sample_count: new_scan.sample_count,
1180            total_bytes: new_scan.total_bytes,
1181            format_counts: new_scan.format_counts.clone(),
1182            roots: new_scan.roots.clone(),
1183        },
1184        added,
1185        removed,
1186    }
1187}
1188
1189pub fn save_audio_scan(samples: &[AudioSample], roots: &[String]) -> AudioScanSnapshot {
1190    let mut history = load_audio_history();
1191    let mut format_counts = std::collections::HashMap::new();
1192    let mut total_bytes = 0u64;
1193    for s in samples {
1194        *format_counts.entry(s.format.clone()).or_insert(0) += 1;
1195        total_bytes += s.size;
1196    }
1197    let snapshot = AudioScanSnapshot {
1198        id: gen_id(),
1199        timestamp: now_iso(),
1200        sample_count: samples.len(),
1201        total_bytes,
1202        format_counts,
1203        samples: samples.to_vec(),
1204        roots: roots.to_vec(),
1205    };
1206    history.scans.insert(0, snapshot.clone());
1207    if history.scans.len() > 50 {
1208        history.scans.truncate(50);
1209    }
1210    save_audio_history(&history);
1211    snapshot
1212}
1213
1214pub fn get_audio_scans() -> Vec<AudioScanSummary> {
1215    let history = load_audio_history();
1216    history
1217        .scans
1218        .iter()
1219        .map(|s| AudioScanSummary {
1220            id: s.id.clone(),
1221            timestamp: s.timestamp.clone(),
1222            sample_count: s.sample_count,
1223            total_bytes: s.total_bytes,
1224            format_counts: s.format_counts.clone(),
1225            roots: s.roots.clone(),
1226        })
1227        .collect()
1228}
1229
1230pub fn get_audio_scan_detail(id: &str) -> Option<AudioScanSnapshot> {
1231    let history = load_audio_history();
1232    history.scans.into_iter().find(|s| s.id == id)
1233}
1234
1235pub fn delete_audio_scan(id: &str) {
1236    let mut history = load_audio_history();
1237    history.scans.retain(|s| s.id != id);
1238    save_audio_history(&history);
1239}
1240
1241pub fn clear_audio_history() {
1242    save_audio_history(&AudioHistory { scans: vec![] });
1243}
1244
1245pub fn get_latest_audio_scan() -> Option<AudioScanSnapshot> {
1246    let history = load_audio_history();
1247    history.scans.into_iter().next()
1248}
1249
1250// ── DAW scan history ──
1251
1252fn load_daw_history() -> DawHistory {
1253    let path = daw_history_file();
1254    if path.exists() {
1255        if let Ok(data) = fs::read_to_string(&path) {
1256            if let Ok(h) = serde_json::from_str(&data) {
1257                return h;
1258            }
1259        }
1260    }
1261    DawHistory { scans: vec![] }
1262}
1263
1264fn save_daw_history(history: &DawHistory) {
1265    let path = daw_history_file();
1266    if let Ok(data) = serde_json::to_string_pretty(history) {
1267        let _ = fs::write(path, data);
1268    }
1269}
1270
1271pub fn build_daw_snapshot(projects: &[DawProject], roots: &[String]) -> DawScanSnapshot {
1272    let mut daw_counts = std::collections::HashMap::new();
1273    let mut total_bytes = 0u64;
1274    for p in projects {
1275        *daw_counts.entry(p.daw.clone()).or_insert(0) += 1;
1276        total_bytes += p.size;
1277    }
1278    DawScanSnapshot {
1279        id: gen_id(),
1280        timestamp: now_iso(),
1281        project_count: projects.len(),
1282        total_bytes,
1283        daw_counts,
1284        projects: projects.to_vec(),
1285        roots: roots.to_vec(),
1286    }
1287}
1288
1289pub fn compute_daw_diff(old_scan: &DawScanSnapshot, new_scan: &DawScanSnapshot) -> DawScanDiff {
1290    let old_paths: std::collections::HashSet<&str> =
1291        old_scan.projects.iter().map(|p| p.path.as_str()).collect();
1292    let new_paths: std::collections::HashSet<&str> =
1293        new_scan.projects.iter().map(|p| p.path.as_str()).collect();
1294    let added: Vec<DawProject> = new_scan
1295        .projects
1296        .iter()
1297        .filter(|p| !old_paths.contains(p.path.as_str()))
1298        .cloned()
1299        .collect();
1300    let removed: Vec<DawProject> = old_scan
1301        .projects
1302        .iter()
1303        .filter(|p| !new_paths.contains(p.path.as_str()))
1304        .cloned()
1305        .collect();
1306    DawScanDiff {
1307        old_scan: DawScanSummary {
1308            id: old_scan.id.clone(),
1309            timestamp: old_scan.timestamp.clone(),
1310            project_count: old_scan.project_count,
1311            total_bytes: old_scan.total_bytes,
1312            daw_counts: old_scan.daw_counts.clone(),
1313            roots: old_scan.roots.clone(),
1314        },
1315        new_scan: DawScanSummary {
1316            id: new_scan.id.clone(),
1317            timestamp: new_scan.timestamp.clone(),
1318            project_count: new_scan.project_count,
1319            total_bytes: new_scan.total_bytes,
1320            daw_counts: new_scan.daw_counts.clone(),
1321            roots: new_scan.roots.clone(),
1322        },
1323        added,
1324        removed,
1325    }
1326}
1327
1328pub fn save_daw_scan(projects: &[DawProject], roots: &[String]) -> DawScanSnapshot {
1329    let mut history = load_daw_history();
1330    let mut daw_counts = std::collections::HashMap::new();
1331    let mut total_bytes = 0u64;
1332    for p in projects {
1333        *daw_counts.entry(p.daw.clone()).or_insert(0) += 1;
1334        total_bytes += p.size;
1335    }
1336    let snapshot = DawScanSnapshot {
1337        id: gen_id(),
1338        timestamp: now_iso(),
1339        project_count: projects.len(),
1340        total_bytes,
1341        daw_counts,
1342        projects: projects.to_vec(),
1343        roots: roots.to_vec(),
1344    };
1345    history.scans.insert(0, snapshot.clone());
1346    if history.scans.len() > 50 {
1347        history.scans.truncate(50);
1348    }
1349    save_daw_history(&history);
1350    snapshot
1351}
1352
1353pub fn get_daw_scans() -> Vec<DawScanSummary> {
1354    let history = load_daw_history();
1355    history
1356        .scans
1357        .iter()
1358        .map(|s| DawScanSummary {
1359            id: s.id.clone(),
1360            timestamp: s.timestamp.clone(),
1361            project_count: s.project_count,
1362            total_bytes: s.total_bytes,
1363            daw_counts: s.daw_counts.clone(),
1364            roots: s.roots.clone(),
1365        })
1366        .collect()
1367}
1368
1369pub fn get_daw_scan_detail(id: &str) -> Option<DawScanSnapshot> {
1370    let history = load_daw_history();
1371    history.scans.into_iter().find(|s| s.id == id)
1372}
1373
1374pub fn delete_daw_scan(id: &str) {
1375    let mut history = load_daw_history();
1376    history.scans.retain(|s| s.id != id);
1377    save_daw_history(&history);
1378}
1379
1380pub fn clear_daw_history() {
1381    save_daw_history(&DawHistory { scans: vec![] });
1382}
1383
1384pub fn get_latest_daw_scan() -> Option<DawScanSnapshot> {
1385    let history = load_daw_history();
1386    history.scans.into_iter().next()
1387}
1388
1389pub fn diff_daw_scans(old_id: &str, new_id: &str) -> Option<DawScanDiff> {
1390    let history = load_daw_history();
1391    let old_scan = history.scans.iter().find(|s| s.id == old_id)?;
1392    let new_scan = history.scans.iter().find(|s| s.id == new_id)?;
1393
1394    let old_paths: std::collections::HashSet<&str> =
1395        old_scan.projects.iter().map(|p| p.path.as_str()).collect();
1396    let new_paths: std::collections::HashSet<&str> =
1397        new_scan.projects.iter().map(|p| p.path.as_str()).collect();
1398
1399    let added: Vec<DawProject> = new_scan
1400        .projects
1401        .iter()
1402        .filter(|p| !old_paths.contains(p.path.as_str()))
1403        .cloned()
1404        .collect();
1405
1406    let removed: Vec<DawProject> = old_scan
1407        .projects
1408        .iter()
1409        .filter(|p| !new_paths.contains(p.path.as_str()))
1410        .cloned()
1411        .collect();
1412
1413    Some(DawScanDiff {
1414        old_scan: DawScanSummary {
1415            id: old_scan.id.clone(),
1416            timestamp: old_scan.timestamp.clone(),
1417            project_count: old_scan.project_count,
1418            total_bytes: old_scan.total_bytes,
1419            daw_counts: old_scan.daw_counts.clone(),
1420            roots: old_scan.roots.clone(),
1421        },
1422        new_scan: DawScanSummary {
1423            id: new_scan.id.clone(),
1424            timestamp: new_scan.timestamp.clone(),
1425            project_count: new_scan.project_count,
1426            total_bytes: new_scan.total_bytes,
1427            daw_counts: new_scan.daw_counts.clone(),
1428            roots: new_scan.roots.clone(),
1429        },
1430        added,
1431        removed,
1432    })
1433}
1434
1435pub fn diff_audio_scans(old_id: &str, new_id: &str) -> Option<AudioScanDiff> {
1436    let history = load_audio_history();
1437    let old_scan = history.scans.iter().find(|s| s.id == old_id)?;
1438    let new_scan = history.scans.iter().find(|s| s.id == new_id)?;
1439
1440    let old_paths: std::collections::HashSet<&str> =
1441        old_scan.samples.iter().map(|s| s.path.as_str()).collect();
1442    let new_paths: std::collections::HashSet<&str> =
1443        new_scan.samples.iter().map(|s| s.path.as_str()).collect();
1444
1445    let added: Vec<AudioSample> = new_scan
1446        .samples
1447        .iter()
1448        .filter(|s| !old_paths.contains(s.path.as_str()))
1449        .cloned()
1450        .collect();
1451
1452    let removed: Vec<AudioSample> = old_scan
1453        .samples
1454        .iter()
1455        .filter(|s| !new_paths.contains(s.path.as_str()))
1456        .cloned()
1457        .collect();
1458
1459    Some(AudioScanDiff {
1460        old_scan: AudioScanSummary {
1461            id: old_scan.id.clone(),
1462            timestamp: old_scan.timestamp.clone(),
1463            sample_count: old_scan.sample_count,
1464            total_bytes: old_scan.total_bytes,
1465            format_counts: old_scan.format_counts.clone(),
1466            roots: old_scan.roots.clone(),
1467        },
1468        new_scan: AudioScanSummary {
1469            id: new_scan.id.clone(),
1470            timestamp: new_scan.timestamp.clone(),
1471            sample_count: new_scan.sample_count,
1472            total_bytes: new_scan.total_bytes,
1473            format_counts: new_scan.format_counts.clone(),
1474            roots: new_scan.roots.clone(),
1475        },
1476        added,
1477        removed,
1478    })
1479}
1480
1481// ── Preset scan history ──
1482
1483fn preset_history_file() -> PathBuf {
1484    ensure_data_dir().join("preset-scan-history.json")
1485}
1486
1487fn load_preset_history() -> PresetHistory {
1488    let path = preset_history_file();
1489    if path.exists() {
1490        if let Ok(data) = fs::read_to_string(&path) {
1491            if let Ok(h) = serde_json::from_str(&data) {
1492                return h;
1493            }
1494        }
1495    }
1496    PresetHistory { scans: vec![] }
1497}
1498
1499fn save_preset_history(history: &PresetHistory) {
1500    let path = preset_history_file();
1501    if let Ok(json) = serde_json::to_string(&history) {
1502        let _ = fs::write(&path, json);
1503    }
1504}
1505
1506pub fn build_preset_snapshot(presets: &[PresetFile], roots: &[String]) -> PresetScanSnapshot {
1507    let mut format_counts = std::collections::HashMap::new();
1508    let mut total_bytes = 0u64;
1509    for p in presets {
1510        *format_counts.entry(p.format.clone()).or_insert(0) += 1;
1511        total_bytes += p.size;
1512    }
1513    PresetScanSnapshot {
1514        id: gen_id(),
1515        timestamp: now_iso(),
1516        preset_count: presets.len(),
1517        total_bytes,
1518        format_counts,
1519        presets: presets.to_vec(),
1520        roots: roots.to_vec(),
1521    }
1522}
1523
1524pub fn build_midi_snapshot(midi_files: &[MidiFile], roots: &[String]) -> MidiScanSnapshot {
1525    let mut format_counts = std::collections::HashMap::new();
1526    let mut total_bytes = 0u64;
1527    for m in midi_files {
1528        *format_counts.entry(m.format.clone()).or_insert(0) += 1;
1529        total_bytes += m.size;
1530    }
1531    MidiScanSnapshot {
1532        id: gen_id(),
1533        timestamp: now_iso(),
1534        midi_count: midi_files.len(),
1535        total_bytes,
1536        format_counts,
1537        midi_files: midi_files.to_vec(),
1538        roots: roots.to_vec(),
1539    }
1540}
1541
1542pub fn compute_midi_diff(old_scan: &MidiScanSnapshot, new_scan: &MidiScanSnapshot) -> MidiScanDiff {
1543    let old_paths: std::collections::HashSet<&str> =
1544        old_scan.midi_files.iter().map(|m| m.path.as_str()).collect();
1545    let new_paths: std::collections::HashSet<&str> =
1546        new_scan.midi_files.iter().map(|m| m.path.as_str()).collect();
1547    let added: Vec<MidiFile> = new_scan
1548        .midi_files
1549        .iter()
1550        .filter(|m| !old_paths.contains(m.path.as_str()))
1551        .cloned()
1552        .collect();
1553    let removed: Vec<MidiFile> = old_scan
1554        .midi_files
1555        .iter()
1556        .filter(|m| !new_paths.contains(m.path.as_str()))
1557        .cloned()
1558        .collect();
1559    MidiScanDiff {
1560        old_scan: MidiScanSummary {
1561            id: old_scan.id.clone(),
1562            timestamp: old_scan.timestamp.clone(),
1563            midi_count: old_scan.midi_count,
1564            total_bytes: old_scan.total_bytes,
1565            format_counts: old_scan.format_counts.clone(),
1566            roots: old_scan.roots.clone(),
1567        },
1568        new_scan: MidiScanSummary {
1569            id: new_scan.id.clone(),
1570            timestamp: new_scan.timestamp.clone(),
1571            midi_count: new_scan.midi_count,
1572            total_bytes: new_scan.total_bytes,
1573            format_counts: new_scan.format_counts.clone(),
1574            roots: new_scan.roots.clone(),
1575        },
1576        added,
1577        removed,
1578    }
1579}
1580
1581pub fn compute_preset_diff(
1582    old_scan: &PresetScanSnapshot,
1583    new_scan: &PresetScanSnapshot,
1584) -> PresetScanDiff {
1585    let old_paths: std::collections::HashSet<&str> =
1586        old_scan.presets.iter().map(|p| p.path.as_str()).collect();
1587    let new_paths: std::collections::HashSet<&str> =
1588        new_scan.presets.iter().map(|p| p.path.as_str()).collect();
1589    let added: Vec<PresetFile> = new_scan
1590        .presets
1591        .iter()
1592        .filter(|p| !old_paths.contains(p.path.as_str()))
1593        .cloned()
1594        .collect();
1595    let removed: Vec<PresetFile> = old_scan
1596        .presets
1597        .iter()
1598        .filter(|p| !new_paths.contains(p.path.as_str()))
1599        .cloned()
1600        .collect();
1601    PresetScanDiff {
1602        old_scan: PresetScanSummary {
1603            id: old_scan.id.clone(),
1604            timestamp: old_scan.timestamp.clone(),
1605            preset_count: old_scan.preset_count,
1606            total_bytes: old_scan.total_bytes,
1607            format_counts: old_scan.format_counts.clone(),
1608            roots: old_scan.roots.clone(),
1609        },
1610        new_scan: PresetScanSummary {
1611            id: new_scan.id.clone(),
1612            timestamp: new_scan.timestamp.clone(),
1613            preset_count: new_scan.preset_count,
1614            total_bytes: new_scan.total_bytes,
1615            format_counts: new_scan.format_counts.clone(),
1616            roots: new_scan.roots.clone(),
1617        },
1618        added,
1619        removed,
1620    }
1621}
1622
1623pub fn build_pdf_snapshot(pdfs: &[PdfFile], roots: &[String]) -> PdfScanSnapshot {
1624    let mut total_bytes = 0u64;
1625    for p in pdfs {
1626        total_bytes += p.size;
1627    }
1628    PdfScanSnapshot {
1629        id: gen_id(),
1630        timestamp: now_iso(),
1631        pdf_count: pdfs.len(),
1632        total_bytes,
1633        pdfs: pdfs.to_vec(),
1634        roots: roots.to_vec(),
1635    }
1636}
1637
1638pub fn compute_pdf_diff(old_scan: &PdfScanSnapshot, new_scan: &PdfScanSnapshot) -> PdfScanDiff {
1639    let old_paths: std::collections::HashSet<&str> =
1640        old_scan.pdfs.iter().map(|p| p.path.as_str()).collect();
1641    let new_paths: std::collections::HashSet<&str> =
1642        new_scan.pdfs.iter().map(|p| p.path.as_str()).collect();
1643    let added: Vec<PdfFile> = new_scan
1644        .pdfs
1645        .iter()
1646        .filter(|p| !old_paths.contains(p.path.as_str()))
1647        .cloned()
1648        .collect();
1649    let removed: Vec<PdfFile> = old_scan
1650        .pdfs
1651        .iter()
1652        .filter(|p| !new_paths.contains(p.path.as_str()))
1653        .cloned()
1654        .collect();
1655    PdfScanDiff {
1656        old_scan: PdfScanSummary {
1657            id: old_scan.id.clone(),
1658            timestamp: old_scan.timestamp.clone(),
1659            pdf_count: old_scan.pdf_count,
1660            total_bytes: old_scan.total_bytes,
1661            roots: old_scan.roots.clone(),
1662        },
1663        new_scan: PdfScanSummary {
1664            id: new_scan.id.clone(),
1665            timestamp: new_scan.timestamp.clone(),
1666            pdf_count: new_scan.pdf_count,
1667            total_bytes: new_scan.total_bytes,
1668            roots: new_scan.roots.clone(),
1669        },
1670        added,
1671        removed,
1672    }
1673}
1674
1675pub fn save_preset_scan(presets: &[PresetFile], roots: &[String]) -> PresetScanSnapshot {
1676    let mut history = load_preset_history();
1677    let mut format_counts = std::collections::HashMap::new();
1678    let mut total_bytes = 0u64;
1679    for p in presets {
1680        *format_counts.entry(p.format.clone()).or_insert(0) += 1;
1681        total_bytes += p.size;
1682    }
1683    let snapshot = PresetScanSnapshot {
1684        id: gen_id(),
1685        timestamp: now_iso(),
1686        preset_count: presets.len(),
1687        total_bytes,
1688        format_counts,
1689        presets: presets.to_vec(),
1690        roots: roots.to_vec(),
1691    };
1692    history.scans.insert(0, snapshot.clone());
1693    if history.scans.len() > 50 {
1694        history.scans.truncate(50);
1695    }
1696    save_preset_history(&history);
1697    snapshot
1698}
1699
1700pub fn get_preset_scans() -> Vec<PresetScanSummary> {
1701    let history = load_preset_history();
1702    history
1703        .scans
1704        .iter()
1705        .map(|s| PresetScanSummary {
1706            id: s.id.clone(),
1707            timestamp: s.timestamp.clone(),
1708            preset_count: s.preset_count,
1709            total_bytes: s.total_bytes,
1710            format_counts: s.format_counts.clone(),
1711            roots: s.roots.clone(),
1712        })
1713        .collect()
1714}
1715
1716pub fn get_preset_scan_detail(id: &str) -> Option<PresetScanSnapshot> {
1717    let history = load_preset_history();
1718    history.scans.into_iter().find(|s| s.id == id)
1719}
1720
1721pub fn delete_preset_scan(id: &str) {
1722    let mut history = load_preset_history();
1723    history.scans.retain(|s| s.id != id);
1724    save_preset_history(&history);
1725}
1726
1727pub fn clear_preset_history() {
1728    save_preset_history(&PresetHistory { scans: vec![] });
1729}
1730
1731pub fn get_latest_preset_scan() -> Option<PresetScanSnapshot> {
1732    let history = load_preset_history();
1733    history.scans.into_iter().next()
1734}
1735
1736pub fn diff_preset_scans(old_id: &str, new_id: &str) -> Option<PresetScanDiff> {
1737    let history = load_preset_history();
1738    let old_scan = history.scans.iter().find(|s| s.id == old_id)?.clone();
1739    let new_scan = history.scans.iter().find(|s| s.id == new_id)?.clone();
1740
1741    let old_paths: std::collections::HashSet<&str> =
1742        old_scan.presets.iter().map(|p| p.path.as_str()).collect();
1743    let new_paths: std::collections::HashSet<&str> =
1744        new_scan.presets.iter().map(|p| p.path.as_str()).collect();
1745
1746    let added: Vec<PresetFile> = new_scan
1747        .presets
1748        .iter()
1749        .filter(|p| !old_paths.contains(p.path.as_str()))
1750        .cloned()
1751        .collect();
1752
1753    let removed: Vec<PresetFile> = old_scan
1754        .presets
1755        .iter()
1756        .filter(|p| !new_paths.contains(p.path.as_str()))
1757        .cloned()
1758        .collect();
1759
1760    Some(PresetScanDiff {
1761        old_scan: PresetScanSummary {
1762            id: old_scan.id.clone(),
1763            timestamp: old_scan.timestamp.clone(),
1764            preset_count: old_scan.preset_count,
1765            total_bytes: old_scan.total_bytes,
1766            format_counts: old_scan.format_counts.clone(),
1767            roots: old_scan.roots.clone(),
1768        },
1769        new_scan: PresetScanSummary {
1770            id: new_scan.id.clone(),
1771            timestamp: new_scan.timestamp.clone(),
1772            preset_count: new_scan.preset_count,
1773            total_bytes: new_scan.total_bytes,
1774            format_counts: new_scan.format_counts.clone(),
1775            roots: new_scan.roots.clone(),
1776        },
1777        added,
1778        removed,
1779    })
1780}
1781
1782#[cfg(test)]
1783mod tests {
1784    use super::*;
1785
1786    #[test]
1787    fn test_radix_string_base36() {
1788        assert_eq!(radix_string(0, 36), "0");
1789        assert_eq!(radix_string(35, 36), "z");
1790        assert_eq!(radix_string(36, 36), "10");
1791        assert_eq!(radix_string(100, 36), "2s");
1792    }
1793
1794    #[test]
1795    fn test_radix_string_base10() {
1796        assert_eq!(radix_string(0, 10), "0");
1797        assert_eq!(radix_string(42, 10), "42");
1798        assert_eq!(radix_string(999, 10), "999");
1799    }
1800
1801    #[test]
1802    fn test_radix_string_base2() {
1803        assert_eq!(radix_string(0, 2), "0");
1804        assert_eq!(radix_string(8, 2), "1000");
1805        assert_eq!(radix_string(255, 2), "11111111");
1806    }
1807
1808    #[test]
1809    fn test_radix_string_base16_hex_word() {
1810        assert_eq!(radix_string(0xDEADBEEF, 16), "deadbeef");
1811    }
1812
1813    #[test]
1814    fn test_toml_key_to_flat_maps_data_widths_to_flat_column_widths() {
1815        assert_eq!(
1816            toml_key_to_flat("data", "widths").as_deref(),
1817            Some("columnWidths")
1818        );
1819    }
1820
1821    #[test]
1822    fn test_toml_key_to_flat_unknown_returns_none() {
1823        assert_eq!(toml_key_to_flat("not_a_section", "theme"), None);
1824    }
1825
1826    #[test]
1827    fn test_toml_key_to_flat_performance_page_size_to_flat_key() {
1828        assert_eq!(
1829            toml_key_to_flat("performance", "pageSize").as_deref(),
1830            Some("pageSize")
1831        );
1832    }
1833
1834    #[test]
1835    fn test_toml_key_to_flat_favorites_items_to_flat_key() {
1836        assert_eq!(
1837            toml_key_to_flat("favorites", "items").as_deref(),
1838            Some("favorites")
1839        );
1840    }
1841
1842    #[test]
1843    fn test_migrate_json_string_bracketed_invalid_json_keeps_original() {
1844        let v = migrate_json_string(serde_json::json!("[not valid json]"));
1845        assert_eq!(v, serde_json::json!("[not valid json]"));
1846    }
1847
1848    #[test]
1849    fn test_toml_to_flat_promotes_data_widths_key() {
1850        let toml = "[data]\nwidths = [120, 240]\n";
1851        let flat = toml_to_flat(toml);
1852        assert_eq!(
1853            flat.get("columnWidths"),
1854            Some(&serde_json::json!([120, 240]))
1855        );
1856    }
1857
1858    #[test]
1859    fn test_migrate_json_string_parses_bracketed_json_array() {
1860        let v = migrate_json_string(serde_json::json!("[1, 2, 3]"));
1861        assert_eq!(v, serde_json::json!([1, 2, 3]));
1862    }
1863
1864    #[test]
1865    fn test_migrate_json_string_parses_braced_json_object() {
1866        let v = migrate_json_string(serde_json::json!(r#"{"x":1}"#));
1867        assert_eq!(v, serde_json::json!({"x": 1}));
1868    }
1869
1870    #[test]
1871    fn test_migrate_json_string_invalid_json_keeps_original_string() {
1872        let v = migrate_json_string(serde_json::json!("{broken"));
1873        assert_eq!(v, serde_json::json!("{broken"));
1874    }
1875
1876    #[test]
1877    fn test_migrate_json_string_plain_text_not_migrated() {
1878        let v = migrate_json_string(serde_json::json!("just text"));
1879        assert_eq!(v, serde_json::json!("just text"));
1880    }
1881
1882    #[test]
1883    fn test_migrate_json_string_empty_object_string() {
1884        let v = migrate_json_string(serde_json::json!("{}"));
1885        assert_eq!(v, serde_json::json!({}));
1886    }
1887
1888    #[test]
1889    fn test_migrate_json_string_padded_bracket_array_strips_and_parses() {
1890        let v = migrate_json_string(serde_json::json!("  [7]  "));
1891        assert_eq!(v, serde_json::json!([7]));
1892    }
1893
1894    #[test]
1895    fn test_toml_to_flat_invalid_toml_returns_empty_prefs() {
1896        assert!(toml_to_flat("this is not [[valid]] toml").is_empty());
1897    }
1898
1899    #[test]
1900    fn test_toml_to_flat_unknown_section_keeps_inner_keys_as_flat_names() {
1901        let t = "[orphan]\nanswer = 42\n";
1902        let flat = toml_to_flat(t);
1903        assert_eq!(flat.get("answer"), Some(&serde_json::json!(42)));
1904    }
1905
1906    #[test]
1907    fn test_json_to_toml_value_null_maps_to_empty_string() {
1908        let v = json_to_toml_value(&serde_json::Value::Null);
1909        assert!(matches!(v, toml::Value::String(s) if s.is_empty()));
1910    }
1911
1912    #[test]
1913    fn test_json_to_toml_value_non_integer_number_is_float() {
1914        let v = json_to_toml_value(&serde_json::json!(3.14159));
1915        assert!(matches!(v, toml::Value::Float(f) if (f - 3.14159).abs() < 1e-5));
1916    }
1917
1918    #[test]
1919    fn test_json_to_toml_value_bool() {
1920        assert!(matches!(
1921            json_to_toml_value(&serde_json::Value::Bool(true)),
1922            toml::Value::Boolean(true)
1923        ));
1924        assert!(matches!(
1925            json_to_toml_value(&serde_json::Value::Bool(false)),
1926            toml::Value::Boolean(false)
1927        ));
1928    }
1929
1930    #[test]
1931    fn test_toml_value_to_json_bool_round_trips() {
1932        let j = toml_value_to_json(&toml::Value::Boolean(true));
1933        assert_eq!(j, serde_json::Value::Bool(true));
1934    }
1935
1936    #[test]
1937    fn test_toml_value_to_json_integer_round_trips() {
1938        let t = toml::Value::Integer(-42);
1939        let j = toml_value_to_json(&t);
1940        assert_eq!(j, serde_json::json!(-42));
1941    }
1942
1943    #[test]
1944    fn test_json_to_toml_nested_object_and_array_round_trip() {
1945        let j = serde_json::json!({
1946            "nested": { "x": 1, "flag": true },
1947            "arr": [1, 2, 3],
1948            "empty": {}
1949        });
1950        let t = json_to_toml_value(&j);
1951        let back = toml_value_to_json(&t);
1952        assert_eq!(back, j);
1953    }
1954
1955    #[test]
1956    fn test_build_audio_snapshot_aggregates_formats_and_total_bytes() {
1957        let samples = vec![
1958            AudioSample {
1959                name: "a".into(),
1960                path: "/a.wav".into(),
1961                directory: "/tmp".into(),
1962                format: "WAV".into(),
1963                size: 100,
1964                size_formatted: "100 B".into(),
1965                modified: "t".into(),
1966                duration: None,
1967                channels: None,
1968                sample_rate: None,
1969                bits_per_sample: None,
1970            },
1971            AudioSample {
1972                name: "b".into(),
1973                path: "/b.wav".into(),
1974                directory: "/tmp".into(),
1975                format: "WAV".into(),
1976                size: 200,
1977                size_formatted: "200 B".into(),
1978                modified: "t".into(),
1979                duration: None,
1980                channels: None,
1981                sample_rate: None,
1982                bits_per_sample: None,
1983            },
1984            AudioSample {
1985                name: "c".into(),
1986                path: "/c.mp3".into(),
1987                directory: "/tmp".into(),
1988                format: "MP3".into(),
1989                size: 50,
1990                size_formatted: "50 B".into(),
1991                modified: "t".into(),
1992                duration: None,
1993                channels: None,
1994                sample_rate: None,
1995                bits_per_sample: None,
1996            },
1997        ];
1998        let roots = vec!["/music".into()];
1999        let snap = build_audio_snapshot(&samples, &roots);
2000        assert_eq!(snap.sample_count, 3);
2001        assert_eq!(snap.total_bytes, 350);
2002        assert_eq!(snap.format_counts.get("WAV"), Some(&2));
2003        assert_eq!(snap.format_counts.get("MP3"), Some(&1));
2004        assert_eq!(snap.roots, roots);
2005    }
2006
2007    #[test]
2008    fn test_build_plugin_snapshot_counts_and_roots_match_input() {
2009        let plugins = vec![
2010            make_plugin("Alpha", "1.0", "/tmp/a.vst3"),
2011            make_plugin("Beta", "2.0", "/tmp/b.vst3"),
2012        ];
2013        let dirs = vec!["/tmp/plugins".into()];
2014        let roots = vec!["/root/A".into(), "/root/B".into()];
2015        let snap = build_plugin_snapshot(&plugins, &dirs, &roots);
2016        assert_eq!(snap.plugin_count, 2);
2017        assert_eq!(snap.plugins.len(), 2);
2018        assert_eq!(snap.directories, dirs);
2019        assert_eq!(snap.roots, roots);
2020    }
2021
2022    #[test]
2023    fn test_build_preset_snapshot_aggregates_formats_and_total_bytes() {
2024        let presets = vec![
2025            PresetFile {
2026                name: "lead".into(),
2027                path: "/p/lead.fxp".into(),
2028                directory: "/p".into(),
2029                format: "FXP".into(),
2030                size: 100,
2031                size_formatted: "100 B".into(),
2032                modified: "m".into(),
2033            },
2034            PresetFile {
2035                name: "pad".into(),
2036                path: "/p/pad.vstpreset".into(),
2037                directory: "/p".into(),
2038                format: "VSTPRESET".into(),
2039                size: 250,
2040                size_formatted: "250 B".into(),
2041                modified: "m".into(),
2042            },
2043        ];
2044        let roots = vec!["/presets".into()];
2045        let snap = build_preset_snapshot(&presets, &roots);
2046        assert_eq!(snap.preset_count, 2);
2047        assert_eq!(snap.total_bytes, 350);
2048        assert_eq!(snap.format_counts.get("FXP"), Some(&1));
2049        assert_eq!(snap.format_counts.get("VSTPRESET"), Some(&1));
2050        assert_eq!(snap.roots, roots);
2051    }
2052
2053    #[test]
2054    fn test_gen_id_unique() {
2055        let id1 = gen_id();
2056        let id2 = gen_id();
2057        assert_ne!(id1, id2);
2058        assert!(!id1.is_empty());
2059    }
2060
2061    #[test]
2062    fn test_gen_id_is_base36_alphanumeric() {
2063        let id = gen_id();
2064        assert!(
2065            id.chars()
2066                .all(|c| c.is_ascii_digit() || ('a'..='z').contains(&c)),
2067            "gen_id must be radix-36 digits only, got {:?}",
2068            id
2069        );
2070    }
2071
2072    #[test]
2073    fn test_now_iso_format() {
2074        let ts = now_iso();
2075        // Should be RFC3339 format
2076        assert!(ts.contains('T'));
2077        assert!(ts.ends_with('Z'));
2078    }
2079
2080    fn with_temp_dir<F: FnOnce(&std::path::Path)>(f: F) {
2081        use std::sync::atomic::{AtomicU64, Ordering};
2082        static COUNTER: AtomicU64 = AtomicU64::new(0);
2083        let id = COUNTER.fetch_add(1, Ordering::SeqCst);
2084        let dir = std::env::temp_dir().join(format!("upum_tmp_{}_{}", std::process::id(), id));
2085        let _ = fs::remove_dir_all(&dir);
2086        let _ = fs::create_dir_all(&dir);
2087        TEST_DATA_DIR.with(|d| *d.borrow_mut() = Some(dir.clone()));
2088        f(&dir);
2089        TEST_DATA_DIR.with(|d| *d.borrow_mut() = None);
2090        let _ = fs::remove_dir_all(&dir);
2091    }
2092
2093    fn with_test_dir<F: FnOnce()>(name: &str, f: F) {
2094        let dir = std::env::temp_dir().join(format!("upum_test_{}", name));
2095        let _ = fs::remove_dir_all(&dir);
2096        let _ = fs::create_dir_all(&dir);
2097        TEST_DATA_DIR.with(|d| *d.borrow_mut() = Some(dir.clone()));
2098        f();
2099        TEST_DATA_DIR.with(|d| *d.borrow_mut() = None);
2100        let _ = fs::remove_dir_all(&dir);
2101    }
2102
2103    fn make_plugin(name: &str, version: &str, path: &str) -> PluginInfo {
2104        PluginInfo {
2105            name: name.into(),
2106            path: path.into(),
2107            plugin_type: "VST3".into(),
2108            version: version.into(),
2109            manufacturer: "TestMfg".into(),
2110            manufacturer_url: None,
2111            size: "1.0 MB".into(),
2112            size_bytes: 1048576,
2113            modified: "2024-01-01".into(),
2114            architectures: vec![],
2115        }
2116    }
2117
2118    fn make_sample(name: &str, path: &str, format: &str) -> AudioSample {
2119        AudioSample {
2120            name: name.into(),
2121            path: path.into(),
2122            directory: "/tmp".into(),
2123            format: format.into(),
2124            size: 1024,
2125            size_formatted: "1.0 KB".into(),
2126            modified: "2024-01-01".into(),
2127            duration: None,
2128            channels: None,
2129            sample_rate: None,
2130            bits_per_sample: None,
2131        }
2132    }
2133
2134    #[test]
2135    fn test_scan_history_crud() {
2136        with_test_dir("scan_crud", || {
2137            let plugins = vec![
2138                make_plugin("PlugA", "1.0", "/tmp/a.vst3"),
2139                make_plugin("PlugB", "2.0", "/tmp/b.vst3"),
2140            ];
2141            let dirs = vec!["/tmp".to_string()];
2142            let snap = save_scan(&plugins, &dirs, &dirs);
2143            assert_eq!(snap.plugin_count, 2);
2144
2145            let scans = get_scans();
2146            assert!(scans.iter().any(|s| s.id == snap.id));
2147
2148            let detail = get_scan_detail(&snap.id);
2149            assert!(detail.is_some());
2150            assert_eq!(detail.unwrap().plugins.len(), 2);
2151
2152            let latest = get_latest_scan();
2153            assert!(latest.is_some());
2154
2155            delete_scan(&snap.id);
2156            assert!(get_scan_detail(&snap.id).is_none());
2157        });
2158    }
2159
2160    #[test]
2161    fn test_scan_history_limit_50() {
2162        with_test_dir("scan_limit", || {
2163            let plugins = vec![make_plugin("P", "1.0", "/tmp/p.vst3")];
2164            let dirs = vec!["/tmp".to_string()];
2165            for _ in 0..55 {
2166                save_scan(&plugins, &dirs, &dirs);
2167            }
2168            let scans = get_scans();
2169            assert!(scans.len() <= 50);
2170        });
2171    }
2172
2173    #[test]
2174    fn test_diff_scans_added_removed() {
2175        with_test_dir("diff_added_removed", || {
2176            let plugins1 = vec![
2177                make_plugin("PlugA", "1.0", "/tmp/a.vst3"),
2178                make_plugin("PlugB", "1.0", "/tmp/b.vst3"),
2179            ];
2180            let plugins2 = vec![
2181                make_plugin("PlugB", "1.0", "/tmp/b.vst3"),
2182                make_plugin("PlugC", "1.0", "/tmp/c.vst3"),
2183            ];
2184            let dirs = vec!["/tmp".to_string()];
2185            let snap1 = save_scan(&plugins1, &dirs, &dirs);
2186            let snap2 = save_scan(&plugins2, &dirs, &dirs);
2187
2188            let diff = diff_scans(&snap1.id, &snap2.id).unwrap();
2189            assert_eq!(diff.added.len(), 1);
2190            assert_eq!(diff.added[0].name, "PlugC");
2191            assert_eq!(diff.removed.len(), 1);
2192            assert_eq!(diff.removed[0].name, "PlugA");
2193        });
2194    }
2195
2196    #[test]
2197    fn test_diff_scans_version_changed() {
2198        with_test_dir("diff_version", || {
2199            let plugins1 = vec![make_plugin("PlugA", "1.0", "/tmp/vc_a.vst3")];
2200            let plugins2 = vec![make_plugin("PlugA", "2.0", "/tmp/vc_a.vst3")];
2201            let dirs = vec!["/tmp".to_string()];
2202            let snap1 = save_scan(&plugins1, &dirs, &dirs);
2203            let snap2 = save_scan(&plugins2, &dirs, &dirs);
2204
2205            let diff = diff_scans(&snap1.id, &snap2.id).unwrap();
2206            assert_eq!(diff.version_changed.len(), 1);
2207            assert_eq!(diff.version_changed[0].previous_version, "1.0");
2208            assert_eq!(diff.version_changed[0].plugin.version, "2.0");
2209        });
2210    }
2211
2212    #[test]
2213    fn test_kvr_cache_crud() {
2214        with_test_dir("kvr_cache", || {
2215            let entries = vec![KvrCacheUpdateEntry {
2216                key: "test-plugin".into(),
2217                kvr_url: Some("https://kvr.com/test".into()),
2218                update_url: None,
2219                latest_version: Some("1.5".into()),
2220                has_update: Some(true),
2221                source: Some("kvr".into()),
2222            }];
2223            update_kvr_cache(&entries);
2224
2225            let cache = load_kvr_cache();
2226            assert!(cache.contains_key("test-plugin"));
2227            let entry = &cache["test-plugin"];
2228            assert_eq!(entry.latest_version, Some("1.5".into()));
2229            assert!(entry.has_update);
2230        });
2231    }
2232
2233    #[test]
2234    fn test_audio_history_crud() {
2235        with_test_dir("audio_crud", || {
2236            let samples = vec![
2237                make_sample("kick", "/tmp/kick.wav", "WAV"),
2238                make_sample("snare", "/tmp/snare.mp3", "MP3"),
2239            ];
2240            let snap = save_audio_scan(&samples, &[]);
2241            assert_eq!(snap.sample_count, 2);
2242            assert_eq!(snap.total_bytes, 2048);
2243            assert_eq!(snap.format_counts.get("WAV"), Some(&1));
2244            assert_eq!(snap.format_counts.get("MP3"), Some(&1));
2245
2246            let scans = get_audio_scans();
2247            assert!(scans.iter().any(|s| s.id == snap.id));
2248
2249            let detail = get_audio_scan_detail(&snap.id).unwrap();
2250            assert_eq!(detail.samples.len(), 2);
2251
2252            let latest = get_latest_audio_scan().unwrap();
2253            assert_eq!(latest.id, snap.id);
2254
2255            delete_audio_scan(&snap.id);
2256            assert!(get_audio_scan_detail(&snap.id).is_none());
2257        });
2258    }
2259
2260    #[test]
2261    fn test_save_scan_preserves_order() {
2262        with_test_dir("scan_order", || {
2263            let dirs = vec!["/tmp".to_string()];
2264            let s1 = save_scan(&[make_plugin("A", "1.0", "/tmp/a.vst3")], &dirs, &dirs);
2265            let s2 = save_scan(&[make_plugin("B", "1.0", "/tmp/b.vst3")], &dirs, &dirs);
2266            let s3 = save_scan(&[make_plugin("C", "1.0", "/tmp/c.vst3")], &dirs, &dirs);
2267
2268            let scans = get_scans();
2269            // Newest first
2270            assert_eq!(scans[0].id, s3.id);
2271            assert_eq!(scans[1].id, s2.id);
2272            assert_eq!(scans[2].id, s1.id);
2273        });
2274    }
2275
2276    #[test]
2277    fn test_delete_nonexistent_scan() {
2278        with_test_dir("delete_nonexistent", || {
2279            let dirs = vec!["/tmp".to_string()];
2280            let snap = save_scan(&[make_plugin("X", "1.0", "/tmp/x.vst3")], &dirs, &dirs);
2281
2282            // Delete a fake id - should not crash
2283            delete_scan("totally-fake-id-12345");
2284
2285            // Original scan should still exist
2286            let detail = get_scan_detail(&snap.id);
2287            assert!(detail.is_some());
2288        });
2289    }
2290
2291    #[test]
2292    fn test_clear_history_idempotent() {
2293        with_test_dir("clear_idempotent", || {
2294            let dirs = vec!["/tmp".to_string()];
2295            save_scan(&[make_plugin("X", "1.0", "/tmp/x.vst3")], &dirs, &dirs);
2296
2297            clear_history();
2298            assert!(get_scans().is_empty());
2299
2300            // Second clear should not crash
2301            clear_history();
2302            assert!(get_scans().is_empty());
2303        });
2304    }
2305
2306    #[test]
2307    fn test_diff_scans_no_changes() {
2308        with_test_dir("diff_no_changes", || {
2309            let plugins = vec![
2310                make_plugin("PlugA", "1.0", "/tmp/a.vst3"),
2311                make_plugin("PlugB", "2.0", "/tmp/b.vst3"),
2312            ];
2313            let dirs = vec!["/tmp".to_string()];
2314            let snap1 = save_scan(&plugins, &dirs, &dirs);
2315            let snap2 = save_scan(&plugins, &dirs, &dirs);
2316
2317            let diff = diff_scans(&snap1.id, &snap2.id).unwrap();
2318            assert!(diff.added.is_empty(), "added should be empty");
2319            assert!(diff.removed.is_empty(), "removed should be empty");
2320            assert!(
2321                diff.version_changed.is_empty(),
2322                "version_changed should be empty"
2323            );
2324        });
2325    }
2326
2327    #[test]
2328    fn test_diff_scans_nonexistent_ids() {
2329        with_test_dir("diff_nonexistent", || {
2330            let result = diff_scans("fake-id-1", "fake-id-2");
2331            assert!(
2332                result.is_none(),
2333                "diff_scans with fake ids should return None"
2334            );
2335        });
2336    }
2337
2338    #[test]
2339    fn test_audio_history_limit_50() {
2340        with_test_dir("audio_limit_50", || {
2341            let sample = vec![make_sample("kick", "/tmp/kick.wav", "WAV")];
2342            for _ in 0..55 {
2343                save_audio_scan(&sample, &[]);
2344            }
2345            let scans = get_audio_scans();
2346            assert!(
2347                scans.len() <= 50,
2348                "Audio history should be limited to 50, got {}",
2349                scans.len()
2350            );
2351        });
2352    }
2353
2354    #[test]
2355    fn test_kvr_cache_update_overwrites() {
2356        with_test_dir("kvr_overwrite", || {
2357            let entries_v1 = vec![KvrCacheUpdateEntry {
2358                key: "my-plugin".into(),
2359                kvr_url: Some("https://kvr.com/my".into()),
2360                update_url: None,
2361                latest_version: Some("1.0".into()),
2362                has_update: Some(false),
2363                source: Some("kvr".into()),
2364            }];
2365            update_kvr_cache(&entries_v1);
2366
2367            let entries_v2 = vec![KvrCacheUpdateEntry {
2368                key: "my-plugin".into(),
2369                kvr_url: Some("https://kvr.com/my".into()),
2370                update_url: Some("https://example.com/dl".into()),
2371                latest_version: Some("2.0".into()),
2372                has_update: Some(true),
2373                source: Some("kvr".into()),
2374            }];
2375            update_kvr_cache(&entries_v2);
2376
2377            let cache = load_kvr_cache();
2378            let entry = &cache["my-plugin"];
2379            assert_eq!(entry.latest_version, Some("2.0".into()));
2380            assert!(entry.has_update);
2381            assert_eq!(entry.update_url, Some("https://example.com/dl".into()));
2382        });
2383    }
2384
2385    #[test]
2386    fn test_kvr_cache_multiple_entries() {
2387        with_test_dir("kvr_multiple", || {
2388            let entries = vec![
2389                KvrCacheUpdateEntry {
2390                    key: "plugin-a".into(),
2391                    kvr_url: Some("https://kvr.com/a".into()),
2392                    update_url: None,
2393                    latest_version: Some("1.0".into()),
2394                    has_update: Some(false),
2395                    source: Some("kvr".into()),
2396                },
2397                KvrCacheUpdateEntry {
2398                    key: "plugin-b".into(),
2399                    kvr_url: Some("https://kvr.com/b".into()),
2400                    update_url: None,
2401                    latest_version: Some("2.0".into()),
2402                    has_update: Some(true),
2403                    source: Some("kvr".into()),
2404                },
2405                KvrCacheUpdateEntry {
2406                    key: "plugin-c".into(),
2407                    kvr_url: Some("https://kvr.com/c".into()),
2408                    update_url: None,
2409                    latest_version: Some("3.0".into()),
2410                    has_update: Some(false),
2411                    source: Some("kvr".into()),
2412                },
2413            ];
2414            update_kvr_cache(&entries);
2415
2416            let cache = load_kvr_cache();
2417            assert!(cache.contains_key("plugin-a"));
2418            assert!(cache.contains_key("plugin-b"));
2419            assert!(cache.contains_key("plugin-c"));
2420            assert_eq!(cache["plugin-a"].latest_version, Some("1.0".into()));
2421            assert_eq!(cache["plugin-b"].latest_version, Some("2.0".into()));
2422            assert_eq!(cache["plugin-c"].latest_version, Some("3.0".into()));
2423        });
2424    }
2425
2426    #[test]
2427    fn test_audio_diff() {
2428        with_test_dir("audio_diff", || {
2429            let samples1 = vec![
2430                make_sample("kick", "/tmp/kick.wav", "WAV"),
2431                make_sample("snare", "/tmp/snare.wav", "WAV"),
2432            ];
2433            let samples2 = vec![
2434                make_sample("snare", "/tmp/snare.wav", "WAV"),
2435                make_sample("hihat", "/tmp/hihat.wav", "WAV"),
2436            ];
2437            let snap1 = save_audio_scan(&samples1, &[]);
2438            let snap2 = save_audio_scan(&samples2, &[]);
2439
2440            let diff = diff_audio_scans(&snap1.id, &snap2.id).unwrap();
2441            assert_eq!(diff.added.len(), 1);
2442            assert_eq!(diff.added[0].name, "hihat");
2443            assert_eq!(diff.removed.len(), 1);
2444            assert_eq!(diff.removed[0].name, "kick");
2445        });
2446    }
2447
2448    fn make_daw_project(name: &str, path: &str, format: &str, daw: &str) -> DawProject {
2449        DawProject {
2450            name: name.into(),
2451            path: path.into(),
2452            directory: "/tmp".into(),
2453            format: format.into(),
2454            daw: daw.into(),
2455            size: 2048,
2456            size_formatted: "2.0 KB".into(),
2457            modified: "2024-01-01".into(),
2458        }
2459    }
2460
2461    #[test]
2462    fn test_daw_history_crud() {
2463        with_test_dir("daw_crud", || {
2464            let projects = vec![
2465                make_daw_project("Song1", "/tmp/song1.als", "ALS", "Ableton Live"),
2466                make_daw_project("Song2", "/tmp/song2.flp", "FLP", "FL Studio"),
2467            ];
2468            let snap = save_daw_scan(&projects, &[]);
2469            assert_eq!(snap.project_count, 2);
2470            assert_eq!(snap.total_bytes, 4096);
2471            assert_eq!(snap.daw_counts.get("Ableton Live"), Some(&1));
2472            assert_eq!(snap.daw_counts.get("FL Studio"), Some(&1));
2473
2474            let scans = get_daw_scans();
2475            assert!(scans.iter().any(|s| s.id == snap.id));
2476
2477            let detail = get_daw_scan_detail(&snap.id).unwrap();
2478            assert_eq!(detail.projects.len(), 2);
2479
2480            let latest = get_latest_daw_scan().unwrap();
2481            assert_eq!(latest.id, snap.id);
2482
2483            delete_daw_scan(&snap.id);
2484            assert!(get_daw_scan_detail(&snap.id).is_none());
2485        });
2486    }
2487
2488    #[test]
2489    fn test_daw_history_limit_50() {
2490        with_test_dir("daw_limit_50", || {
2491            let projects = vec![make_daw_project(
2492                "Song",
2493                "/tmp/song.als",
2494                "ALS",
2495                "Ableton Live",
2496            )];
2497            for _ in 0..55 {
2498                save_daw_scan(&projects, &[]);
2499            }
2500            let scans = get_daw_scans();
2501            assert!(
2502                scans.len() <= 50,
2503                "DAW history should be limited to 50, got {}",
2504                scans.len()
2505            );
2506        });
2507    }
2508
2509    #[test]
2510    fn test_daw_diff() {
2511        with_test_dir("daw_diff", || {
2512            let projects1 = vec![
2513                make_daw_project("Song1", "/tmp/song1.als", "ALS", "Ableton Live"),
2514                make_daw_project("Song2", "/tmp/song2.flp", "FLP", "FL Studio"),
2515            ];
2516            let projects2 = vec![
2517                make_daw_project("Song2", "/tmp/song2.flp", "FLP", "FL Studio"),
2518                make_daw_project("Song3", "/tmp/song3.rpp", "RPP", "REAPER"),
2519            ];
2520            let snap1 = save_daw_scan(&projects1, &[]);
2521            let snap2 = save_daw_scan(&projects2, &[]);
2522
2523            let diff = diff_daw_scans(&snap1.id, &snap2.id).unwrap();
2524            assert_eq!(diff.added.len(), 1);
2525            assert_eq!(diff.added[0].name, "Song3");
2526            assert_eq!(diff.removed.len(), 1);
2527            assert_eq!(diff.removed[0].name, "Song1");
2528        });
2529    }
2530
2531    // ── Preferences tests ──
2532
2533    #[test]
2534    fn test_preferences_roundtrip() {
2535        with_temp_dir(|_| {
2536            let mut prefs = PrefsMap::new();
2537            prefs.insert("theme".into(), serde_json::json!("dark"));
2538            prefs.insert("pageSize".into(), serde_json::json!(500));
2539            prefs.insert("autoScan".into(), serde_json::json!("on"));
2540            save_preferences(&prefs);
2541
2542            let loaded = load_preferences();
2543            assert_eq!(loaded.get("theme"), Some(&serde_json::json!("dark")));
2544            assert_eq!(loaded.get("autoScan"), Some(&serde_json::json!("on")));
2545        });
2546    }
2547
2548    #[test]
2549    fn test_set_and_get_preference() {
2550        with_temp_dir(|_| {
2551            set_preference("testKey", serde_json::json!("testValue"));
2552            let val = get_preference("testKey");
2553            assert_eq!(val, Some(serde_json::json!("testValue")));
2554        });
2555    }
2556
2557    #[test]
2558    fn test_remove_preference() {
2559        with_temp_dir(|_| {
2560            set_preference("removeMe", serde_json::json!(42));
2561            assert!(get_preference("removeMe").is_some());
2562            remove_preference("removeMe");
2563            // After removal, may still return default — check it doesn't crash
2564            let _ = get_preference("removeMe");
2565        });
2566    }
2567
2568    #[test]
2569    fn test_get_nonexistent_preference() {
2570        with_temp_dir(|_| {
2571            let val = get_preference("nonexistent_key_xyz");
2572            // May return a default or None
2573            let _ = val;
2574        });
2575    }
2576
2577    #[test]
2578    fn test_preferences_overwrite() {
2579        with_temp_dir(|_| {
2580            set_preference("color", serde_json::json!("red"));
2581            set_preference("color", serde_json::json!("blue"));
2582            let val = get_preference("color");
2583            assert_eq!(val, Some(serde_json::json!("blue")));
2584        });
2585    }
2586
2587    /// `PREF_CACHE` is global; unit tests use distinct thread-local temp dirs. Without path-keyed
2588    /// cache, parallel tests could read another thread's cached prefs (CI failure: overwrite test).
2589    #[test]
2590    fn test_preferences_cache_isolated_across_parallel_threads() {
2591        use std::thread;
2592        let a = thread::spawn(|| {
2593            with_temp_dir(|_| {
2594                set_preference("parallelIsolationKey", serde_json::json!("thread-a"));
2595                assert_eq!(
2596                    get_preference("parallelIsolationKey"),
2597                    Some(serde_json::json!("thread-a"))
2598                );
2599            });
2600        });
2601        let b = thread::spawn(|| {
2602            with_temp_dir(|_| {
2603                set_preference("parallelIsolationKey", serde_json::json!("thread-b"));
2604                assert_eq!(
2605                    get_preference("parallelIsolationKey"),
2606                    Some(serde_json::json!("thread-b"))
2607                );
2608            });
2609        });
2610        a.join().expect("thread a");
2611        b.join().expect("thread b");
2612    }
2613
2614    // ── Scan detail tests ──
2615
2616    #[test]
2617    fn test_get_scan_detail_found() {
2618        with_temp_dir(|_| {
2619            let plugins = vec![make_plugin("DetailPlugin", "1.0", "/detail")];
2620            save_scan(&plugins, &["/detail".into()], &["/detail".into()]);
2621
2622            let scans = get_scans();
2623            assert!(!scans.is_empty());
2624            let id = &scans[0].id;
2625
2626            let detail = get_scan_detail(id);
2627            assert!(detail.is_some());
2628            let snap = detail.unwrap();
2629            assert_eq!(snap.plugins.len(), 1);
2630            assert_eq!(snap.plugins[0].name, "DetailPlugin");
2631        });
2632    }
2633
2634    #[test]
2635    fn test_get_scan_detail_not_found() {
2636        with_temp_dir(|_| {
2637            let detail = get_scan_detail("nonexistent_id");
2638            assert!(detail.is_none());
2639        });
2640    }
2641
2642    // ── Preset history tests ──
2643
2644    #[test]
2645    fn test_preset_scan_save_and_retrieve() {
2646        with_temp_dir(|_| {
2647            let presets = vec![PresetFile {
2648                name: "BassPreset".into(),
2649                path: "/presets/bass.fxp".into(),
2650                directory: "/presets".into(),
2651                format: "FXP".into(),
2652                size: 1024,
2653                size_formatted: "1.0 KB".into(),
2654                modified: "2024-06-01".into(),
2655            }];
2656            save_preset_scan(&presets, &["/presets".into()]);
2657            let scans = get_preset_scans();
2658            assert_eq!(scans.len(), 1);
2659            assert_eq!(scans[0].preset_count, 1);
2660        });
2661    }
2662
2663    #[test]
2664    fn test_preset_scan_detail() {
2665        with_temp_dir(|_| {
2666            let presets = vec![PresetFile {
2667                name: "LeadPreset".into(),
2668                path: "/presets/lead.fxp".into(),
2669                directory: "/presets".into(),
2670                format: "FXP".into(),
2671                size: 2048,
2672                size_formatted: "2.0 KB".into(),
2673                modified: "2024-07-01".into(),
2674            }];
2675            save_preset_scan(&presets, &["/presets".into()]);
2676            let scans = get_preset_scans();
2677            let detail = get_preset_scan_detail(&scans[0].id);
2678            assert!(detail.is_some());
2679            assert_eq!(detail.unwrap().presets.len(), 1);
2680        });
2681    }
2682
2683    // ── DAW scan detail tests ──
2684
2685    #[test]
2686    fn test_daw_scan_detail() {
2687        with_temp_dir(|_| {
2688            let projects = vec![make_daw_project(
2689                "TestSong",
2690                "/songs/test.als",
2691                "ALS",
2692                "Ableton Live",
2693            )];
2694            save_daw_scan(&projects, &["/songs".into()]);
2695            let scans = get_daw_scans();
2696            let detail = get_daw_scan_detail(&scans[0].id);
2697            assert!(detail.is_some());
2698            assert_eq!(detail.unwrap().projects.len(), 1);
2699        });
2700    }
2701
2702    #[test]
2703    fn test_daw_scan_detail_not_found() {
2704        with_temp_dir(|_| {
2705            let detail = get_daw_scan_detail("nonexistent");
2706            assert!(detail.is_none());
2707        });
2708    }
2709
2710    // ── Merge prefs tests ──
2711
2712    #[test]
2713    fn test_merge_prefs_user_overrides_defaults() {
2714        let mut defaults = PrefsMap::new();
2715        defaults.insert("a".into(), serde_json::json!("default_a"));
2716        defaults.insert("b".into(), serde_json::json!("default_b"));
2717
2718        let mut user = PrefsMap::new();
2719        user.insert("a".into(), serde_json::json!("user_a"));
2720        user.insert("c".into(), serde_json::json!("user_c"));
2721
2722        let merged = merge_prefs(&defaults, &user);
2723        assert_eq!(merged.get("a"), Some(&serde_json::json!("user_a")));
2724        assert_eq!(merged.get("b"), Some(&serde_json::json!("default_b")));
2725        assert_eq!(merged.get("c"), Some(&serde_json::json!("user_c")));
2726    }
2727
2728    #[test]
2729    fn test_merge_prefs_empty_defaults_absorbs_user_only_keys() {
2730        let defaults = PrefsMap::new();
2731        let mut user = PrefsMap::new();
2732        user.insert("custom".into(), serde_json::json!(true));
2733        let merged = merge_prefs(&defaults, &user);
2734        assert_eq!(merged.len(), 1);
2735        assert_eq!(merged.get("custom"), Some(&serde_json::json!(true)));
2736    }
2737
2738    #[test]
2739    fn test_merge_prefs_empty_user_keeps_all_defaults() {
2740        let mut defaults = PrefsMap::new();
2741        defaults.insert("theme".into(), serde_json::json!("dark"));
2742        let user = PrefsMap::new();
2743        let merged = merge_prefs(&defaults, &user);
2744        assert_eq!(merged.get("theme"), Some(&serde_json::json!("dark")));
2745    }
2746
2747    // ── TOML conversion tests ──
2748
2749    #[test]
2750    fn test_flat_to_toml_and_back() {
2751        let mut prefs = PrefsMap::new();
2752        prefs.insert("theme".into(), serde_json::json!("cyberpunk"));
2753        prefs.insert("pageSize".into(), serde_json::json!(1000));
2754        prefs.insert("autoScan".into(), serde_json::json!(true));
2755
2756        let toml_str = flat_to_toml(&prefs);
2757        assert!(toml_str.contains("theme"));
2758
2759        let back = toml_to_flat(&toml_str);
2760        assert_eq!(back.get("theme"), Some(&serde_json::json!("cyberpunk")));
2761    }
2762
2763    // ── Pure diffs (no JSON file I/O): invariants for SQLite / command paths ──
2764
2765    #[test]
2766    fn test_compute_plugin_diff_duplicate_path_last_old_entry_wins_for_version_compare() {
2767        let old = ScanSnapshot {
2768            id: "old".into(),
2769            timestamp: "t1".into(),
2770            plugin_count: 2,
2771            plugins: vec![
2772                make_plugin("Plug", "1.0", "/tmp/dup_path.vst3"),
2773                make_plugin("Plug", "2.0", "/tmp/dup_path.vst3"),
2774            ],
2775            directories: vec![],
2776            roots: vec![],
2777        };
2778        let new = ScanSnapshot {
2779            id: "new".into(),
2780            timestamp: "t2".into(),
2781            plugin_count: 1,
2782            plugins: vec![make_plugin("Plug", "3.0", "/tmp/dup_path.vst3")],
2783            directories: vec![],
2784            roots: vec![],
2785        };
2786        let d = compute_plugin_diff(&old, &new);
2787        assert_eq!(d.version_changed.len(), 1);
2788        assert_eq!(d.version_changed[0].previous_version, "2.0");
2789        assert_eq!(d.version_changed[0].plugin.version, "3.0");
2790    }
2791
2792    #[test]
2793    fn test_compute_plugin_diff_both_empty_snapshots() {
2794        let old = ScanSnapshot {
2795            id: "a".into(),
2796            timestamp: "t1".into(),
2797            plugin_count: 0,
2798            plugins: vec![],
2799            directories: vec![],
2800            roots: vec![],
2801        };
2802        let new = ScanSnapshot {
2803            id: "b".into(),
2804            timestamp: "t2".into(),
2805            plugin_count: 0,
2806            plugins: vec![],
2807            directories: vec![],
2808            roots: vec![],
2809        };
2810        let d = compute_plugin_diff(&old, &new);
2811        assert!(d.added.is_empty());
2812        assert!(d.removed.is_empty());
2813        assert!(d.version_changed.is_empty());
2814    }
2815
2816    #[test]
2817    fn test_compute_plugin_diff_same_known_version_no_version_changed() {
2818        let p = make_plugin("Same", "1.2.3", "/tmp/same.vst3");
2819        let old = ScanSnapshot {
2820            id: "o".into(),
2821            timestamp: "t1".into(),
2822            plugin_count: 1,
2823            plugins: vec![p.clone()],
2824            directories: vec![],
2825            roots: vec![],
2826        };
2827        let new = ScanSnapshot {
2828            id: "n".into(),
2829            timestamp: "t2".into(),
2830            plugin_count: 1,
2831            plugins: vec![p],
2832            directories: vec![],
2833            roots: vec![],
2834        };
2835        let d = compute_plugin_diff(&old, &new);
2836        assert!(d.version_changed.is_empty());
2837        assert!(d.added.is_empty());
2838        assert!(d.removed.is_empty());
2839    }
2840
2841    #[test]
2842    fn test_compute_plugin_diff_unknown_to_known_same_path_no_version_changed() {
2843        let old = ScanSnapshot {
2844            id: "o".into(),
2845            timestamp: "t1".into(),
2846            plugin_count: 1,
2847            plugins: vec![make_plugin("P", "Unknown", "/x/plugin.vst3")],
2848            directories: vec![],
2849            roots: vec![],
2850        };
2851        let new = ScanSnapshot {
2852            id: "n".into(),
2853            timestamp: "t2".into(),
2854            plugin_count: 1,
2855            plugins: vec![make_plugin("P", "1.0.0", "/x/plugin.vst3")],
2856            directories: vec![],
2857            roots: vec![],
2858        };
2859        let d = compute_plugin_diff(&old, &new);
2860        assert!(
2861            d.version_changed.is_empty(),
2862            "Unknown→known should not emit version_changed (scanner ambiguity)"
2863        );
2864    }
2865
2866    #[test]
2867    fn test_compute_plugin_diff_both_unknown_same_path_no_version_changed() {
2868        let p = make_plugin("Q", "Unknown", "/y/plugin.vst3");
2869        let old = ScanSnapshot {
2870            id: "o".into(),
2871            timestamp: "t1".into(),
2872            plugin_count: 1,
2873            plugins: vec![p.clone()],
2874            directories: vec![],
2875            roots: vec![],
2876        };
2877        let new = ScanSnapshot {
2878            id: "n".into(),
2879            timestamp: "t2".into(),
2880            plugin_count: 1,
2881            plugins: vec![p],
2882            directories: vec![],
2883            roots: vec![],
2884        };
2885        assert!(compute_plugin_diff(&old, &new).version_changed.is_empty());
2886    }
2887
2888    #[test]
2889    fn test_compute_plugin_diff_known_to_unknown_same_path_no_version_changed() {
2890        let old = ScanSnapshot {
2891            id: "o".into(),
2892            timestamp: "t1".into(),
2893            plugin_count: 1,
2894            plugins: vec![make_plugin("R", "2.1.0", "/z/plugin.vst3")],
2895            directories: vec![],
2896            roots: vec![],
2897        };
2898        let new = ScanSnapshot {
2899            id: "n".into(),
2900            timestamp: "t2".into(),
2901            plugin_count: 1,
2902            plugins: vec![make_plugin("R", "Unknown", "/z/plugin.vst3")],
2903            directories: vec![],
2904            roots: vec![],
2905        };
2906        assert!(
2907            compute_plugin_diff(&old, &new).version_changed.is_empty(),
2908            "lost version info should not emit version_changed"
2909        );
2910    }
2911
2912    #[test]
2913    fn test_compute_plugin_diff_version_downgrade_emits_version_changed() {
2914        let old = ScanSnapshot {
2915            id: "old".into(),
2916            timestamp: "t1".into(),
2917            plugin_count: 1,
2918            plugins: vec![make_plugin("X", "2.0", "/p/down.vst3")],
2919            directories: vec![],
2920            roots: vec![],
2921        };
2922        let new = ScanSnapshot {
2923            id: "new".into(),
2924            timestamp: "t2".into(),
2925            plugin_count: 1,
2926            plugins: vec![make_plugin("X", "1.0", "/p/down.vst3")],
2927            directories: vec![],
2928            roots: vec![],
2929        };
2930        let d = compute_plugin_diff(&old, &new);
2931        assert_eq!(d.version_changed.len(), 1);
2932        assert_eq!(d.version_changed[0].previous_version, "2.0");
2933        assert_eq!(d.version_changed[0].plugin.version, "1.0");
2934    }
2935
2936    #[test]
2937    fn test_compute_plugin_diff_version_upgrade_emits_version_changed() {
2938        let old = ScanSnapshot {
2939            id: "old".into(),
2940            timestamp: "t1".into(),
2941            plugin_count: 1,
2942            plugins: vec![make_plugin("Serum", "1.0.0", "/lib/Serum.vst3")],
2943            directories: vec![],
2944            roots: vec![],
2945        };
2946        let new = ScanSnapshot {
2947            id: "new".into(),
2948            timestamp: "t2".into(),
2949            plugin_count: 1,
2950            plugins: vec![make_plugin("Serum", "1.5.2", "/lib/Serum.vst3")],
2951            directories: vec![],
2952            roots: vec![],
2953        };
2954        let d = compute_plugin_diff(&old, &new);
2955        assert_eq!(d.version_changed.len(), 1);
2956        assert_eq!(d.version_changed[0].previous_version, "1.0.0");
2957        assert_eq!(d.version_changed[0].plugin.version, "1.5.2");
2958        assert!(d.added.is_empty());
2959        assert!(d.removed.is_empty());
2960    }
2961
2962    /// Same scan diff path: one plugin upgrades, one path removed, one path added.
2963    #[test]
2964    fn test_compute_plugin_diff_upgrade_add_remove_flow() {
2965        let old = ScanSnapshot {
2966            id: "o".into(),
2967            timestamp: "t1".into(),
2968            plugin_count: 2,
2969            plugins: vec![
2970                make_plugin("Stay", "1.0.0", "/keep/plugin.vst3"),
2971                make_plugin("Gone", "1.0", "/old/gone.vst3"),
2972            ],
2973            directories: vec![],
2974            roots: vec![],
2975        };
2976        let new = ScanSnapshot {
2977            id: "n".into(),
2978            timestamp: "t2".into(),
2979            plugin_count: 2,
2980            plugins: vec![
2981                make_plugin("Stay", "2.0.0", "/keep/plugin.vst3"),
2982                make_plugin("New", "1.0", "/new/new.vst3"),
2983            ],
2984            directories: vec![],
2985            roots: vec![],
2986        };
2987        let d = compute_plugin_diff(&old, &new);
2988        assert_eq!(d.version_changed.len(), 1);
2989        assert_eq!(d.version_changed[0].previous_version, "1.0.0");
2990        assert_eq!(d.version_changed[0].plugin.version, "2.0.0");
2991        assert_eq!(d.added.len(), 1);
2992        assert_eq!(d.added[0].path, "/new/new.vst3");
2993        assert_eq!(d.removed.len(), 1);
2994        assert_eq!(d.removed[0].path, "/old/gone.vst3");
2995    }
2996
2997    #[test]
2998    fn test_compute_audio_diff_both_empty() {
2999        let old = AudioScanSnapshot {
3000            id: "a".into(),
3001            timestamp: "t1".into(),
3002            sample_count: 0,
3003            total_bytes: 0,
3004            format_counts: std::collections::HashMap::new(),
3005            samples: vec![],
3006            roots: vec![],
3007        };
3008        let new = AudioScanSnapshot {
3009            id: "b".into(),
3010            timestamp: "t2".into(),
3011            sample_count: 0,
3012            total_bytes: 0,
3013            format_counts: std::collections::HashMap::new(),
3014            samples: vec![],
3015            roots: vec![],
3016        };
3017        let d = compute_audio_diff(&old, &new);
3018        assert!(d.added.is_empty());
3019        assert!(d.removed.is_empty());
3020    }
3021
3022    #[test]
3023    fn test_compute_daw_diff_both_empty() {
3024        let old = DawScanSnapshot {
3025            id: "a".into(),
3026            timestamp: "t1".into(),
3027            project_count: 0,
3028            total_bytes: 0,
3029            daw_counts: std::collections::HashMap::new(),
3030            projects: vec![],
3031            roots: vec![],
3032        };
3033        let new = DawScanSnapshot {
3034            id: "b".into(),
3035            timestamp: "t2".into(),
3036            project_count: 0,
3037            total_bytes: 0,
3038            daw_counts: std::collections::HashMap::new(),
3039            projects: vec![],
3040            roots: vec![],
3041        };
3042        let d = compute_daw_diff(&old, &new);
3043        assert!(d.added.is_empty());
3044        assert!(d.removed.is_empty());
3045    }
3046
3047    #[test]
3048    fn test_compute_preset_diff_both_empty() {
3049        let old = PresetScanSnapshot {
3050            id: "a".into(),
3051            timestamp: "t1".into(),
3052            preset_count: 0,
3053            total_bytes: 0,
3054            format_counts: std::collections::HashMap::new(),
3055            presets: vec![],
3056            roots: vec![],
3057        };
3058        let new = PresetScanSnapshot {
3059            id: "b".into(),
3060            timestamp: "t2".into(),
3061            preset_count: 0,
3062            total_bytes: 0,
3063            format_counts: std::collections::HashMap::new(),
3064            presets: vec![],
3065            roots: vec![],
3066        };
3067        let d = compute_preset_diff(&old, &new);
3068        assert!(d.added.is_empty());
3069        assert!(d.removed.is_empty());
3070    }
3071
3072    #[test]
3073    fn test_compute_audio_diff_added_removed_by_path() {
3074        let old = AudioScanSnapshot {
3075            id: "o".into(),
3076            timestamp: "t1".into(),
3077            sample_count: 1,
3078            total_bytes: 100,
3079            format_counts: std::collections::HashMap::new(),
3080            samples: vec![make_sample("kick", "/tmp/kick.wav", "WAV")],
3081            roots: vec![],
3082        };
3083        let new = AudioScanSnapshot {
3084            id: "n".into(),
3085            timestamp: "t2".into(),
3086            sample_count: 1,
3087            total_bytes: 200,
3088            format_counts: std::collections::HashMap::new(),
3089            samples: vec![make_sample("snare", "/tmp/snare.wav", "WAV")],
3090            roots: vec![],
3091        };
3092        let d = compute_audio_diff(&old, &new);
3093        assert_eq!(d.added.len(), 1);
3094        assert_eq!(d.added[0].path, "/tmp/snare.wav");
3095        assert_eq!(d.removed.len(), 1);
3096        assert_eq!(d.removed[0].path, "/tmp/kick.wav");
3097    }
3098
3099    /// One path stable, one removed, one added — same set logic as plugin/DAW diffs.
3100    #[test]
3101    fn test_compute_audio_diff_keep_add_remove_flow() {
3102        let old = AudioScanSnapshot {
3103            id: "o".into(),
3104            timestamp: "t1".into(),
3105            sample_count: 2,
3106            total_bytes: 2048,
3107            format_counts: std::collections::HashMap::new(),
3108            samples: vec![
3109                make_sample("shared", "/lib/shared.wav", "WAV"),
3110                make_sample("gone", "/old/gone.wav", "WAV"),
3111            ],
3112            roots: vec![],
3113        };
3114        let new = AudioScanSnapshot {
3115            id: "n".into(),
3116            timestamp: "t2".into(),
3117            sample_count: 2,
3118            total_bytes: 2048,
3119            format_counts: std::collections::HashMap::new(),
3120            samples: vec![
3121                make_sample("shared", "/lib/shared.wav", "WAV"),
3122                make_sample("fresh", "/new/fresh.wav", "WAV"),
3123            ],
3124            roots: vec![],
3125        };
3126        let d = compute_audio_diff(&old, &new);
3127        assert_eq!(d.added.len(), 1);
3128        assert_eq!(d.removed.len(), 1);
3129        assert_eq!(d.added[0].path, "/new/fresh.wav");
3130        assert_eq!(d.removed[0].path, "/old/gone.wav");
3131    }
3132
3133    #[test]
3134    fn test_build_daw_snapshot_aggregates_daw_counts_and_total_bytes() {
3135        let projects = vec![
3136            make_daw_project("A", "/p/a.als", "ALS", "Ableton Live"),
3137            make_daw_project("B", "/p/b.flp", "FLP", "FL Studio"),
3138            make_daw_project("C", "/p/c.flp", "FLP", "FL Studio"),
3139        ];
3140        let roots = vec!["/projects".into()];
3141        let snap = build_daw_snapshot(&projects, &roots);
3142        assert_eq!(snap.project_count, 3);
3143        assert_eq!(snap.total_bytes, 2048 * 3);
3144        assert_eq!(snap.daw_counts.get("Ableton Live"), Some(&1));
3145        assert_eq!(snap.daw_counts.get("FL Studio"), Some(&2));
3146        assert_eq!(snap.roots, roots);
3147    }
3148
3149    #[test]
3150    fn test_compute_daw_diff_added_removed_by_path() {
3151        let old = DawScanSnapshot {
3152            id: "o".into(),
3153            timestamp: "t1".into(),
3154            project_count: 1,
3155            total_bytes: 1000,
3156            daw_counts: std::collections::HashMap::new(),
3157            projects: vec![make_daw_project("Old", "/p/old.als", "ALS", "Ableton Live")],
3158            roots: vec![],
3159        };
3160        let new = DawScanSnapshot {
3161            id: "n".into(),
3162            timestamp: "t2".into(),
3163            project_count: 1,
3164            total_bytes: 2000,
3165            daw_counts: std::collections::HashMap::new(),
3166            projects: vec![make_daw_project("New", "/p/new.flp", "FLP", "FL Studio")],
3167            roots: vec![],
3168        };
3169        let d = compute_daw_diff(&old, &new);
3170        assert_eq!(d.added.len(), 1);
3171        assert_eq!(d.added[0].path, "/p/new.flp");
3172        assert_eq!(d.removed.len(), 1);
3173        assert_eq!(d.removed[0].path, "/p/old.als");
3174    }
3175
3176    #[test]
3177    fn test_compute_daw_diff_same_paths_no_added_or_removed() {
3178        let p = make_daw_project("Live", "/projects/set.als", "ALS", "Ableton Live");
3179        let old = DawScanSnapshot {
3180            id: "o".into(),
3181            timestamp: "t1".into(),
3182            project_count: 1,
3183            total_bytes: 1000,
3184            daw_counts: std::collections::HashMap::new(),
3185            projects: vec![p.clone()],
3186            roots: vec!["/a".into()],
3187        };
3188        let new = DawScanSnapshot {
3189            id: "n".into(),
3190            timestamp: "t2".into(),
3191            project_count: 1,
3192            total_bytes: 999_000,
3193            daw_counts: std::collections::HashMap::new(),
3194            projects: vec![p],
3195            roots: vec!["/b".into()],
3196        };
3197        let d = compute_daw_diff(&old, &new);
3198        assert!(d.added.is_empty());
3199        assert!(d.removed.is_empty());
3200    }
3201
3202    #[test]
3203    fn test_compute_daw_diff_keep_add_remove_flow() {
3204        let old = DawScanSnapshot {
3205            id: "o".into(),
3206            timestamp: "t1".into(),
3207            project_count: 2,
3208            total_bytes: 4096,
3209            daw_counts: std::collections::HashMap::new(),
3210            projects: vec![
3211                make_daw_project("Stay", "/projects/stay.als", "ALS", "Ableton Live"),
3212                make_daw_project("Gone", "/old/gone.flp", "FLP", "FL Studio"),
3213            ],
3214            roots: vec![],
3215        };
3216        let new = DawScanSnapshot {
3217            id: "n".into(),
3218            timestamp: "t2".into(),
3219            project_count: 2,
3220            total_bytes: 4096,
3221            daw_counts: std::collections::HashMap::new(),
3222            projects: vec![
3223                make_daw_project("Stay", "/projects/stay.als", "ALS", "Ableton Live"),
3224                make_daw_project("Fresh", "/new/fresh.cpr", "CPR", "Cubase"),
3225            ],
3226            roots: vec![],
3227        };
3228        let d = compute_daw_diff(&old, &new);
3229        assert_eq!(d.added.len(), 1);
3230        assert_eq!(d.removed.len(), 1);
3231        assert_eq!(d.added[0].path, "/new/fresh.cpr");
3232        assert_eq!(d.removed[0].path, "/old/gone.flp");
3233    }
3234
3235    #[test]
3236    fn test_compute_preset_diff_added_removed_by_path() {
3237        let preset = |path: &str| PresetFile {
3238            name: "n".into(),
3239            path: path.into(),
3240            directory: "/d".into(),
3241            format: "FXP".into(),
3242            size: 10,
3243            size_formatted: "10 B".into(),
3244            modified: "2024-01-01".into(),
3245        };
3246        let old = PresetScanSnapshot {
3247            id: "o".into(),
3248            timestamp: "t1".into(),
3249            preset_count: 1,
3250            total_bytes: 10,
3251            format_counts: std::collections::HashMap::new(),
3252            presets: vec![preset("/a/a.fxp")],
3253            roots: vec![],
3254        };
3255        let new = PresetScanSnapshot {
3256            id: "n".into(),
3257            timestamp: "t2".into(),
3258            preset_count: 1,
3259            total_bytes: 20,
3260            format_counts: std::collections::HashMap::new(),
3261            presets: vec![preset("/b/b.fxp")],
3262            roots: vec![],
3263        };
3264        let d = compute_preset_diff(&old, &new);
3265        assert_eq!(d.added.len(), 1);
3266        assert_eq!(d.added[0].path, "/b/b.fxp");
3267        assert_eq!(d.removed.len(), 1);
3268        assert_eq!(d.removed[0].path, "/a/a.fxp");
3269    }
3270
3271    #[test]
3272    fn test_compute_preset_diff_keep_add_remove_flow() {
3273        let p = |path: &str, name: &str| PresetFile {
3274            name: name.into(),
3275            path: path.into(),
3276            directory: "/d".into(),
3277            format: "FXP".into(),
3278            size: 10,
3279            size_formatted: "10 B".into(),
3280            modified: "2024-01-01".into(),
3281        };
3282        let old = PresetScanSnapshot {
3283            id: "o".into(),
3284            timestamp: "t1".into(),
3285            preset_count: 2,
3286            total_bytes: 20,
3287            format_counts: std::collections::HashMap::new(),
3288            presets: vec![p("/p/keep.fxp", "keep"), p("/p/old.fxp", "old")],
3289            roots: vec![],
3290        };
3291        let new = PresetScanSnapshot {
3292            id: "n".into(),
3293            timestamp: "t2".into(),
3294            preset_count: 2,
3295            total_bytes: 20,
3296            format_counts: std::collections::HashMap::new(),
3297            presets: vec![p("/p/keep.fxp", "keep"), p("/p/new.fxp", "new")],
3298            roots: vec![],
3299        };
3300        let d = compute_preset_diff(&old, &new);
3301        assert_eq!(d.added.len(), 1);
3302        assert_eq!(d.removed.len(), 1);
3303        assert_eq!(d.added[0].path, "/p/new.fxp");
3304        assert_eq!(d.removed[0].path, "/p/old.fxp");
3305    }
3306
3307    #[test]
3308    fn test_compute_audio_diff_same_paths_no_added_or_removed() {
3309        let old = AudioScanSnapshot {
3310            id: "o".into(),
3311            timestamp: "t1".into(),
3312            sample_count: 1,
3313            total_bytes: 100,
3314            format_counts: std::collections::HashMap::new(),
3315            samples: vec![make_sample("kick", "/tmp/shared.wav", "WAV")],
3316            roots: vec![],
3317        };
3318        let new = AudioScanSnapshot {
3319            id: "n".into(),
3320            timestamp: "t2".into(),
3321            sample_count: 1,
3322            total_bytes: 9999,
3323            format_counts: std::collections::HashMap::new(),
3324            samples: vec![make_sample("kick", "/tmp/shared.wav", "WAV")],
3325            roots: vec![],
3326        };
3327        let d = compute_audio_diff(&old, &new);
3328        assert!(d.added.is_empty());
3329        assert!(d.removed.is_empty());
3330    }
3331
3332    #[test]
3333    fn test_compute_preset_diff_same_paths_no_added_or_removed() {
3334        let p = PresetFile {
3335            name: "lead".into(),
3336            path: "/x/lead.fxp".into(),
3337            directory: "/x".into(),
3338            format: "FXP".into(),
3339            size: 10,
3340            size_formatted: "10 B".into(),
3341            modified: "d".into(),
3342        };
3343        let old = PresetScanSnapshot {
3344            id: "o".into(),
3345            timestamp: "t1".into(),
3346            preset_count: 1,
3347            total_bytes: 10,
3348            format_counts: std::collections::HashMap::new(),
3349            presets: vec![p.clone()],
3350            roots: vec![],
3351        };
3352        let new = PresetScanSnapshot {
3353            id: "n".into(),
3354            timestamp: "t2".into(),
3355            preset_count: 1,
3356            total_bytes: 99,
3357            format_counts: std::collections::HashMap::new(),
3358            presets: vec![p],
3359            roots: vec![],
3360        };
3361        let d = compute_preset_diff(&old, &new);
3362        assert!(d.added.is_empty());
3363        assert!(d.removed.is_empty());
3364    }
3365
3366    #[test]
3367    fn test_build_pdf_snapshot_sums_bytes_and_roots() {
3368        let pdfs = vec![
3369            PdfFile {
3370                name: "a".into(),
3371                path: "/a/a.pdf".into(),
3372                directory: "/a".into(),
3373                size: 100,
3374                size_formatted: "100 B".into(),
3375                modified: "d".into(),
3376            },
3377            PdfFile {
3378                name: "b".into(),
3379                path: "/b/b.pdf".into(),
3380                directory: "/b".into(),
3381                size: 200,
3382                size_formatted: "200 B".into(),
3383                modified: "d".into(),
3384            },
3385        ];
3386        let snap = build_pdf_snapshot(&pdfs, &["/roots".into()]);
3387        assert_eq!(snap.pdf_count, 2);
3388        assert_eq!(snap.total_bytes, 300);
3389        assert_eq!(snap.roots, vec!["/roots".to_string()]);
3390        assert!(!snap.id.is_empty());
3391    }
3392
3393    #[test]
3394    fn test_compute_pdf_diff_added_removed_by_path() {
3395        let mk = |path: &str| PdfFile {
3396            name: "n".into(),
3397            path: path.into(),
3398            directory: "/d".into(),
3399            size: 1,
3400            size_formatted: "1 B".into(),
3401            modified: "2024-01-01".into(),
3402        };
3403        let old = PdfScanSnapshot {
3404            id: "o".into(),
3405            timestamp: "t1".into(),
3406            pdf_count: 1,
3407            total_bytes: 1,
3408            pdfs: vec![mk("/old/a.pdf")],
3409            roots: vec![],
3410        };
3411        let new = PdfScanSnapshot {
3412            id: "n".into(),
3413            timestamp: "t2".into(),
3414            pdf_count: 1,
3415            total_bytes: 1,
3416            pdfs: vec![mk("/new/b.pdf")],
3417            roots: vec![],
3418        };
3419        let d = compute_pdf_diff(&old, &new);
3420        assert_eq!(d.added.len(), 1);
3421        assert_eq!(d.removed.len(), 1);
3422        assert_eq!(d.added[0].path, "/new/b.pdf");
3423        assert_eq!(d.removed[0].path, "/old/a.pdf");
3424    }
3425
3426    #[test]
3427    fn test_compute_pdf_diff_same_paths_no_delta() {
3428        let p = PdfFile {
3429            name: "same".into(),
3430            path: "/x/doc.pdf".into(),
3431            directory: "/x".into(),
3432            size: 10,
3433            size_formatted: "10 B".into(),
3434            modified: "d".into(),
3435        };
3436        let old = PdfScanSnapshot {
3437            id: "o".into(),
3438            timestamp: "t1".into(),
3439            pdf_count: 1,
3440            total_bytes: 10,
3441            pdfs: vec![p.clone()],
3442            roots: vec![],
3443        };
3444        let new = PdfScanSnapshot {
3445            id: "n".into(),
3446            timestamp: "t2".into(),
3447            pdf_count: 1,
3448            total_bytes: 99,
3449            pdfs: vec![p],
3450            roots: vec![],
3451        };
3452        let d = compute_pdf_diff(&old, &new);
3453        assert!(d.added.is_empty());
3454        assert!(d.removed.is_empty());
3455    }
3456}