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#[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#[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#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
275pub struct MidiFile {
276 pub name: String,
277 pub path: String,
278 pub directory: String,
279 pub format: String, 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#[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
378const APP_DATA_IDENTIFIER: &str = "com.menketechnologies.audio-haxor";
380
381#[cfg(test)]
385static TEST_DATA_DIR_GLOBAL: Mutex<Option<PathBuf>> = Mutex::new(None);
386
387fn 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 std::env::temp_dir().join(format!("audio-haxor-{APP_DATA_IDENTIFIER}"))
423}
424
425#[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
442pub 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
479static PREF_CACHE: Mutex<Option<(u64, PathBuf, PrefsMap)>> = Mutex::new(None);
483const PREF_CACHE_TTL_MS: u64 = 2000;
484static 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
501const 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
576fn 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
590fn 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
606fn 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
682fn flat_to_toml(prefs: &PrefsMap) -> String {
684 let mut root = toml::Table::new();
685
686 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 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 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
856fn 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
877pub 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
1010pub 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
1067pub 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
1106fn 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
1127pub 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
1146pub 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
1250fn 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
1481fn 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 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 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_scan("totally-fake-id-12345");
2284
2285 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 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 #[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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}