Skip to main content

max / audiofiles

23.3 KB · 669 lines History Blame Raw
1 //! Search query builder: text search, filter by BPM/key/duration/classification/tags.
2
3 use crate::db::Database;
4 use crate::error::Result;
5 use crate::id_types::{NodeId, VfsId};
6 use crate::vfs::{self as vfs_mod, VfsNodeWithAnalysis};
7 use tracing::instrument;
8
9 /// Search scope: current folder or global across all VFS roots.
10 #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
11 pub enum SearchScope {
12 CurrentFolder,
13 Global,
14 }
15
16 /// Key filter mode: exact match or expand to compatible keys.
17 #[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
18 pub enum KeyFilterMode {
19 /// Match only the selected keys.
20 #[default]
21 Exact,
22 /// Expand each selected key to include its compatible keys (relative + adjacent on circle of fifths).
23 Compatible,
24 }
25
26 /// Filter criteria for searching samples.
27 #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
28 #[serde(default)]
29 pub struct SearchFilter {
30 pub text_query: String,
31 pub bpm_min: Option<f64>,
32 pub bpm_max: Option<f64>,
33 pub keys: Vec<String>,
34 pub key_mode: KeyFilterMode,
35 pub duration_min: Option<f64>,
36 pub duration_max: Option<f64>,
37 pub peak_db_min: Option<f64>,
38 pub peak_db_max: Option<f64>,
39 pub classifications: Vec<String>,
40 pub required_tags: Vec<String>,
41 pub scope: SearchScope,
42 }
43
44 impl Default for SearchFilter {
45 fn default() -> Self {
46 Self {
47 text_query: String::new(),
48 bpm_min: None,
49 bpm_max: None,
50 keys: Vec::new(),
51 key_mode: KeyFilterMode::Exact,
52 duration_min: None,
53 duration_max: None,
54 peak_db_min: None,
55 peak_db_max: None,
56 classifications: Vec::new(),
57 required_tags: Vec::new(),
58 scope: SearchScope::CurrentFolder,
59 }
60 }
61 }
62
63 impl SearchFilter {
64 /// Returns true if any filter is active.
65 pub fn is_active(&self) -> bool {
66 !self.text_query.is_empty()
67 || self.bpm_min.is_some()
68 || self.bpm_max.is_some()
69 || !self.keys.is_empty()
70 || self.duration_min.is_some()
71 || self.duration_max.is_some()
72 || self.peak_db_min.is_some()
73 || self.peak_db_max.is_some()
74 || !self.classifications.is_empty()
75 || !self.required_tags.is_empty()
76 }
77
78 /// Count distinct active filter categories (excluding text query).
79 pub fn active_count(&self) -> usize {
80 let mut n = 0;
81 if self.bpm_min.is_some() || self.bpm_max.is_some() { n += 1; }
82 if self.duration_min.is_some() || self.duration_max.is_some() { n += 1; }
83 if self.peak_db_min.is_some() || self.peak_db_max.is_some() { n += 1; }
84 if !self.classifications.is_empty() { n += 1; }
85 if !self.keys.is_empty() { n += 1; }
86 if !self.required_tags.is_empty() { n += 1; }
87 n
88 }
89
90 /// Clear all filter criteria.
91 pub fn clear(&mut self) {
92 *self = Self::default();
93 }
94
95 /// Generate a human-readable description of active filters for use as a default collection name.
96 pub fn describe(&self) -> String {
97 let mut parts = Vec::new();
98
99 if !self.text_query.is_empty() {
100 parts.push(format!("\"{}\"", self.text_query));
101 }
102 if let (Some(min), Some(max)) = (self.bpm_min, self.bpm_max) {
103 parts.push(format!("BPM {:.0}-{:.0}", min, max));
104 } else if let Some(min) = self.bpm_min {
105 parts.push(format!("BPM {:.0}+", min));
106 } else if let Some(max) = self.bpm_max {
107 parts.push(format!("BPM <{:.0}", max));
108 }
109 if let (Some(min), Some(max)) = (self.duration_min, self.duration_max) {
110 parts.push(format!("{:.1}-{:.1}s", min, max));
111 } else if let Some(min) = self.duration_min {
112 parts.push(format!(">{:.1}s", min));
113 } else if let Some(max) = self.duration_max {
114 parts.push(format!("<{:.1}s", max));
115 }
116 if let (Some(min), Some(max)) = (self.peak_db_min, self.peak_db_max) {
117 parts.push(format!("{:.0} to {:.0} dB", min, max));
118 } else if let Some(min) = self.peak_db_min {
119 parts.push(format!(">{:.0} dB", min));
120 } else if let Some(max) = self.peak_db_max {
121 parts.push(format!("<{:.0} dB", max));
122 }
123 if !self.classifications.is_empty() {
124 parts.push(self.classifications.join(", "));
125 }
126 if !self.keys.is_empty() {
127 parts.push(self.keys.join(", "));
128 }
129 if !self.required_tags.is_empty() {
130 parts.push(self.required_tags.iter().map(|t| format!("#{t}")).collect::<Vec<_>>().join(" "));
131 }
132
133 if parts.is_empty() {
134 "All samples".to_string()
135 } else {
136 parts.join(" | ")
137 }
138 }
139 }
140
141 /// Search within a specific VFS folder.
142 #[instrument(skip_all)]
143 pub fn search_in_folder(
144 db: &Database,
145 filter: &SearchFilter,
146 vfs_id: VfsId,
147 parent_id: Option<NodeId>,
148 ) -> Result<Vec<VfsNodeWithAnalysis>> {
149 let mut sql = String::from(
150 "SELECT n.id, n.vfs_id, n.parent_id, n.name, n.node_type, n.sample_hash, n.created_at,
151 a.bpm, a.musical_key, COALESCE(a.duration, s.duration), a.classification, a.peak_db, a.is_loop,
152 s.cloud_only
153 FROM vfs_nodes n
154 LEFT JOIN audio_analysis a ON n.sample_hash = a.hash
155 LEFT JOIN samples s ON n.sample_hash = s.hash
156 WHERE n.vfs_id = ?1 AND n.parent_id IS ?2 AND s.deleted_at IS NULL",
157 );
158 let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
159 params.push(Box::new(vfs_id));
160 params.push(Box::new(parent_id));
161
162 append_filter_clauses(&mut sql, &mut params, filter);
163
164 sql.push_str(" ORDER BY n.node_type ASC, n.name ASC");
165
166 let mut stmt = db.conn().prepare(&sql)?;
167 let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
168 let rows = stmt.query_map(param_refs.as_slice(), vfs_mod::map_enriched_row)?;
169 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
170 }
171
172 /// Search globally across all VFS roots.
173 #[instrument(skip_all)]
174 pub fn search_global(
175 db: &Database,
176 filter: &SearchFilter,
177 ) -> Result<Vec<VfsNodeWithAnalysis>> {
178 let mut sql = String::from(
179 "SELECT n.id, n.vfs_id, n.parent_id, n.name, n.node_type, n.sample_hash, n.created_at,
180 a.bpm, a.musical_key, COALESCE(a.duration, s.duration), a.classification, a.peak_db, a.is_loop,
181 s.cloud_only
182 FROM vfs_nodes n
183 LEFT JOIN audio_analysis a ON n.sample_hash = a.hash
184 LEFT JOIN samples s ON n.sample_hash = s.hash
185 WHERE s.deleted_at IS NULL",
186 );
187 let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
188
189 append_filter_clauses(&mut sql, &mut params, filter);
190
191 sql.push_str(" ORDER BY n.node_type ASC, n.name ASC LIMIT 500");
192
193 let mut stmt = db.conn().prepare(&sql)?;
194 let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
195 let rows = stmt.query_map(param_refs.as_slice(), vfs_mod::map_enriched_row)?;
196 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
197 }
198
199 /// Return the set of musically compatible keys for a given key.
200 ///
201 /// Uses the circle of fifths: for a major key, returns the key itself, its relative minor,
202 /// and the two adjacent major/minor pairs. For a minor key, finds the relative major first,
203 /// then expands from there. Unknown keys return just themselves.
204 pub fn compatible_keys(key: &str) -> Vec<String> {
205 // Circle of fifths for major keys (and their relative minors).
206 // Each entry: (major, relative_minor)
207 const CIRCLE: &[(&str, &str)] = &[
208 ("C major", "A minor"),
209 ("G major", "E minor"),
210 ("D major", "B minor"),
211 ("A major", "F# minor"),
212 ("E major", "C# minor"),
213 ("B major", "G# minor"),
214 ("F# major", "D# minor"),
215 ("C# major", "A# minor"),
216 ("G# major", "F minor"), // enharmonic: Ab = G#
217 ("D# major", "C minor"), // enharmonic: Eb = D#
218 ("A# major", "G minor"), // enharmonic: Bb = A#
219 ("F major", "D minor"),
220 ];
221
222 // Find position on the circle for a major key
223 let find_major_pos = |major: &str| -> Option<usize> {
224 CIRCLE.iter().position(|(m, _)| *m == major)
225 };
226
227 // Find which major key this key belongs to (either it IS a major key, or it's the relative minor)
228 let major_key = if key.ends_with("major") {
229 key.to_string()
230 } else if key.ends_with("minor") {
231 match CIRCLE.iter().find(|(_, m)| *m == key) {
232 Some((maj, _)) => maj.to_string(),
233 None => return vec![key.to_string()],
234 }
235 } else {
236 return vec![key.to_string()];
237 };
238
239 let pos = match find_major_pos(&major_key) {
240 Some(p) => p,
241 None => return vec![key.to_string()],
242 };
243
244 let len = CIRCLE.len();
245 let left = (pos + len - 1) % len;
246 let right = (pos + 1) % len;
247
248 vec![
249 CIRCLE[pos].0.to_string(),
250 CIRCLE[pos].1.to_string(),
251 CIRCLE[right].0.to_string(),
252 CIRCLE[right].1.to_string(),
253 CIRCLE[left].0.to_string(),
254 CIRCLE[left].1.to_string(),
255 ]
256 }
257
258 fn append_filter_clauses(
259 sql: &mut String,
260 params: &mut Vec<Box<dyn rusqlite::types::ToSql>>,
261 filter: &SearchFilter,
262 ) {
263 if !filter.text_query.is_empty() {
264 let pattern = format!("%{}%", tagtree::escape_like(&filter.text_query));
265 sql.push_str(&format!(
266 " AND (n.name LIKE ?{idx} ESCAPE '\\')",
267 idx = params.len() + 1
268 ));
269 params.push(Box::new(pattern));
270 }
271
272 if let Some(bpm_min) = filter.bpm_min {
273 sql.push_str(&format!(" AND a.bpm >= ?{}", params.len() + 1));
274 params.push(Box::new(bpm_min));
275 }
276
277 if let Some(bpm_max) = filter.bpm_max {
278 sql.push_str(&format!(" AND a.bpm <= ?{}", params.len() + 1));
279 params.push(Box::new(bpm_max));
280 }
281
282 if let Some(dur_min) = filter.duration_min {
283 sql.push_str(&format!(" AND COALESCE(a.duration, s.duration) >= ?{}", params.len() + 1));
284 params.push(Box::new(dur_min));
285 }
286
287 if let Some(dur_max) = filter.duration_max {
288 sql.push_str(&format!(" AND COALESCE(a.duration, s.duration) <= ?{}", params.len() + 1));
289 params.push(Box::new(dur_max));
290 }
291
292 if let Some(peak_min) = filter.peak_db_min {
293 sql.push_str(&format!(" AND a.peak_db >= ?{}", params.len() + 1));
294 params.push(Box::new(peak_min));
295 }
296
297 if let Some(peak_max) = filter.peak_db_max {
298 sql.push_str(&format!(" AND a.peak_db <= ?{}", params.len() + 1));
299 params.push(Box::new(peak_max));
300 }
301
302 if !filter.classifications.is_empty() {
303 let placeholders: Vec<String> = filter
304 .classifications
305 .iter()
306 .enumerate()
307 .map(|(i, _)| format!("?{}", params.len() + 1 + i))
308 .collect();
309 sql.push_str(&format!(
310 " AND a.classification IN ({})",
311 placeholders.join(", ")
312 ));
313 for class in &filter.classifications {
314 params.push(Box::new(class.clone()));
315 }
316 }
317
318 if !filter.keys.is_empty() {
319 use std::collections::HashSet;
320 let expanded: Vec<String> = match filter.key_mode {
321 KeyFilterMode::Compatible => {
322 let mut set = HashSet::new();
323 for key in &filter.keys {
324 for compat in compatible_keys(key) {
325 set.insert(compat);
326 }
327 }
328 set.into_iter().collect()
329 }
330 KeyFilterMode::Exact => filter.keys.clone(),
331 };
332 let placeholders: Vec<String> = expanded
333 .iter()
334 .enumerate()
335 .map(|(i, _)| format!("?{}", params.len() + 1 + i))
336 .collect();
337 sql.push_str(&format!(
338 " AND a.musical_key IN ({})",
339 placeholders.join(", ")
340 ));
341 for key in &expanded {
342 params.push(Box::new(key.clone()));
343 }
344 }
345
346 // Tag filter: no input validation needed here. Tags stored in the `tags` table are
347 // already validated by `validate_tag` on insert (lowercase alphanumeric + dots/hyphens
348 // only), and `escape_like` prevents SQL injection via the LIKE pattern. Filtering
349 // against validated data with escaped patterns is defense-in-depth enough.
350 for tag in &filter.required_tags {
351 sql.push_str(&format!(
352 " AND EXISTS (SELECT 1 FROM tags WHERE sample_hash = n.sample_hash AND tag LIKE ?{} ESCAPE '\\')",
353 params.len() + 1
354 ));
355 let pattern = format!("{}%", tagtree::escape_like(tag));
356 params.push(Box::new(pattern));
357 }
358 }
359
360 #[cfg(test)]
361 mod tests {
362 use super::*;
363 use crate::test_helpers::{insert_fake_sample, insert_sample_with_analysis};
364 use crate::{tags, vfs};
365
366 fn setup_with_samples() -> (Database, VfsId) {
367 let db = Database::open_in_memory().unwrap();
368 insert_fake_sample(&db, "hash1");
369 insert_fake_sample(&db, "hash2");
370
371 let vfs_id = vfs::create_vfs(&db, "Test").unwrap();
372 vfs::create_sample_link(&db, vfs_id, None, "kick.wav", "hash1").unwrap();
373 vfs::create_sample_link(&db, vfs_id, None, "snare.wav", "hash2").unwrap();
374
375 (db, vfs_id)
376 }
377
378 /// Set up a VFS with two analysed samples for filter tests.
379 fn setup_with_analysis() -> (Database, VfsId) {
380 let db = Database::open_in_memory().unwrap();
381 let vfs_id = vfs::create_vfs(&db, "Test").unwrap();
382
383 insert_sample_with_analysis(
384 &db, "fast", "kick_130.wav", vfs_id,
385 Some(130.0), Some("C major"), Some(0.5), Some("kick"),
386 );
387 insert_sample_with_analysis(
388 &db, "slow", "pad_90.wav", vfs_id,
389 Some(90.0), Some("A minor"), Some(5.0), Some("pad"),
390 );
391
392 (db, vfs_id)
393 }
394
395 #[test]
396 fn empty_filter_returns_all() {
397 let (db, vfs_id) = setup_with_samples();
398 let filter = SearchFilter::default();
399 let results = search_in_folder(&db, &filter, vfs_id, None).unwrap();
400 assert_eq!(results.len(), 2);
401 }
402
403 #[test]
404 fn text_filter_matches_name() {
405 let (db, vfs_id) = setup_with_samples();
406 let filter = SearchFilter {
407 text_query: "kick".to_string(),
408 ..Default::default()
409 };
410 let results = search_in_folder(&db, &filter, vfs_id, None).unwrap();
411 assert_eq!(results.len(), 1);
412 assert_eq!(results[0].node.name, "kick.wav");
413 }
414
415 #[test]
416 fn text_filter_no_match() {
417 let (db, vfs_id) = setup_with_samples();
418 let filter = SearchFilter {
419 text_query: "nonexistent".to_string(),
420 ..Default::default()
421 };
422 let results = search_in_folder(&db, &filter, vfs_id, None).unwrap();
423 assert_eq!(results.len(), 0);
424 }
425
426 #[test]
427 fn filter_is_active() {
428 let mut f = SearchFilter::default();
429 assert!(!f.is_active());
430 f.text_query = "test".to_string();
431 assert!(f.is_active());
432 f.clear();
433 assert!(!f.is_active());
434 }
435
436 #[test]
437 fn bpm_min_filter() {
438 let (db, vfs_id) = setup_with_analysis();
439 let filter = SearchFilter {
440 bpm_min: Some(120.0),
441 ..Default::default()
442 };
443 let results = search_in_folder(&db, &filter, vfs_id, None).unwrap();
444 assert_eq!(results.len(), 1);
445 assert_eq!(results[0].node.name, "kick_130.wav");
446 }
447
448 #[test]
449 fn bpm_max_filter() {
450 let (db, vfs_id) = setup_with_analysis();
451 let filter = SearchFilter {
452 bpm_max: Some(100.0),
453 ..Default::default()
454 };
455 let results = search_in_folder(&db, &filter, vfs_id, None).unwrap();
456 assert_eq!(results.len(), 1);
457 assert_eq!(results[0].node.name, "pad_90.wav");
458 }
459
460 #[test]
461 fn bpm_range_filter() {
462 let (db, vfs_id) = setup_with_analysis();
463 // Range that includes both
464 let filter = SearchFilter {
465 bpm_min: Some(80.0),
466 bpm_max: Some(140.0),
467 ..Default::default()
468 };
469 assert_eq!(search_in_folder(&db, &filter, vfs_id, None).unwrap().len(), 2);
470
471 // Range that excludes both
472 let filter = SearchFilter {
473 bpm_min: Some(95.0),
474 bpm_max: Some(125.0),
475 ..Default::default()
476 };
477 assert_eq!(search_in_folder(&db, &filter, vfs_id, None).unwrap().len(), 0);
478 }
479
480 #[test]
481 fn duration_min_filter() {
482 let (db, vfs_id) = setup_with_analysis();
483 let filter = SearchFilter {
484 duration_min: Some(2.0),
485 ..Default::default()
486 };
487 let results = search_in_folder(&db, &filter, vfs_id, None).unwrap();
488 assert_eq!(results.len(), 1);
489 assert_eq!(results[0].node.name, "pad_90.wav");
490 }
491
492 #[test]
493 fn duration_max_filter() {
494 let (db, vfs_id) = setup_with_analysis();
495 let filter = SearchFilter {
496 duration_max: Some(1.0),
497 ..Default::default()
498 };
499 let results = search_in_folder(&db, &filter, vfs_id, None).unwrap();
500 assert_eq!(results.len(), 1);
501 assert_eq!(results[0].node.name, "kick_130.wav");
502 }
503
504 #[test]
505 fn key_filter() {
506 let (db, vfs_id) = setup_with_analysis();
507 let filter = SearchFilter {
508 keys: vec!["A minor".to_string()],
509 ..Default::default()
510 };
511 let results = search_in_folder(&db, &filter, vfs_id, None).unwrap();
512 assert_eq!(results.len(), 1);
513 assert_eq!(results[0].node.name, "pad_90.wav");
514 }
515
516 #[test]
517 fn classification_filter() {
518 let (db, vfs_id) = setup_with_analysis();
519 let filter = SearchFilter {
520 classifications: vec!["kick".to_string()],
521 ..Default::default()
522 };
523 let results = search_in_folder(&db, &filter, vfs_id, None).unwrap();
524 assert_eq!(results.len(), 1);
525 assert_eq!(results[0].node.name, "kick_130.wav");
526 }
527
528 #[test]
529 fn tag_filter() {
530 let (db, vfs_id) = setup_with_analysis();
531 tags::add_tag(&db, "fast", "drums.kick").unwrap();
532
533 let filter = SearchFilter {
534 required_tags: vec!["drums".to_string()],
535 ..Default::default()
536 };
537 let results = search_in_folder(&db, &filter, vfs_id, None).unwrap();
538 assert_eq!(results.len(), 1);
539 assert_eq!(results[0].node.name, "kick_130.wav");
540 }
541
542 #[test]
543 fn combined_filters() {
544 let (db, vfs_id) = setup_with_analysis();
545 // Text + BPM + classification — all match the kick sample
546 let filter = SearchFilter {
547 text_query: "kick".to_string(),
548 bpm_min: Some(120.0),
549 classifications: vec!["kick".to_string()],
550 ..Default::default()
551 };
552 let results = search_in_folder(&db, &filter, vfs_id, None).unwrap();
553 assert_eq!(results.len(), 1);
554 assert_eq!(results[0].node.name, "kick_130.wav");
555
556 // Same filters but text doesn't match — AND logic means zero results
557 let filter = SearchFilter {
558 text_query: "pad".to_string(),
559 bpm_min: Some(120.0),
560 classifications: vec!["kick".to_string()],
561 ..Default::default()
562 };
563 assert_eq!(search_in_folder(&db, &filter, vfs_id, None).unwrap().len(), 0);
564 }
565
566 #[test]
567 fn global_search() {
568 let db = Database::open_in_memory().unwrap();
569 let vfs1 = vfs::create_vfs(&db, "VFS1").unwrap();
570 let vfs2 = vfs::create_vfs(&db, "VFS2").unwrap();
571
572 insert_sample_with_analysis(
573 &db, "h1", "kick.wav", vfs1,
574 Some(128.0), None, None, Some("kick"),
575 );
576 insert_sample_with_analysis(
577 &db, "h2", "snare.wav", vfs2,
578 Some(128.0), None, None, Some("snare"),
579 );
580
581 // Global search finds samples across VFS roots
582 let filter = SearchFilter {
583 classifications: vec!["kick".to_string()],
584 scope: SearchScope::Global,
585 ..Default::default()
586 };
587 let results = search_global(&db, &filter).unwrap();
588 assert_eq!(results.len(), 1);
589 assert_eq!(results[0].node.name, "kick.wav");
590
591 // No filter returns all
592 let filter = SearchFilter {
593 scope: SearchScope::Global,
594 ..Default::default()
595 };
596 assert_eq!(search_global(&db, &filter).unwrap().len(), 2);
597 }
598
599 // --- Key compatibility tests ---
600
601 #[test]
602 fn compatible_keys_major() {
603 let compat = compatible_keys("C major");
604 assert!(compat.contains(&"C major".to_string()));
605 assert!(compat.contains(&"A minor".to_string())); // relative minor
606 assert!(compat.contains(&"G major".to_string())); // adjacent right
607 assert!(compat.contains(&"E minor".to_string()));
608 assert!(compat.contains(&"F major".to_string())); // adjacent left
609 assert!(compat.contains(&"D minor".to_string()));
610 assert_eq!(compat.len(), 6);
611 }
612
613 #[test]
614 fn compatible_keys_minor() {
615 // A minor's relative major is C major, so results should match C major's expansion
616 let compat = compatible_keys("A minor");
617 assert!(compat.contains(&"C major".to_string()));
618 assert!(compat.contains(&"A minor".to_string()));
619 assert!(compat.contains(&"G major".to_string()));
620 assert!(compat.contains(&"F major".to_string()));
621 assert_eq!(compat.len(), 6);
622 }
623
624 #[test]
625 fn compatible_keys_unknown() {
626 let compat = compatible_keys("X dorian");
627 assert_eq!(compat, vec!["X dorian".to_string()]);
628 }
629
630 #[test]
631 fn compatible_key_filter_expands() {
632 let db = Database::open_in_memory().unwrap();
633 let vfs_id = vfs::create_vfs(&db, "Test").unwrap();
634
635 insert_sample_with_analysis(&db, "c", "c.wav", vfs_id, None, Some("C major"), None, None);
636 insert_sample_with_analysis(&db, "am", "am.wav", vfs_id, None, Some("A minor"), None, None);
637 insert_sample_with_analysis(&db, "g", "g.wav", vfs_id, None, Some("G major"), None, None);
638 insert_sample_with_analysis(&db, "eb", "eb.wav", vfs_id, None, Some("D# major"), None, None);
639
640 // Compatible mode: selecting "C major" should match C major, A minor, G major (and E minor, F major, D minor)
641 let filter = SearchFilter {
642 keys: vec!["C major".to_string()],
643 key_mode: KeyFilterMode::Compatible,
644 ..Default::default()
645 };
646 let results = search_in_folder(&db, &filter, vfs_id, None).unwrap();
647 // Should find c.wav, am.wav, g.wav (D# major is not compatible)
648 assert_eq!(results.len(), 3);
649 }
650
651 #[test]
652 fn exact_key_mode_unchanged() {
653 let db = Database::open_in_memory().unwrap();
654 let vfs_id = vfs::create_vfs(&db, "Test").unwrap();
655
656 insert_sample_with_analysis(&db, "c", "c.wav", vfs_id, None, Some("C major"), None, None);
657 insert_sample_with_analysis(&db, "am", "am.wav", vfs_id, None, Some("A minor"), None, None);
658
659 let filter = SearchFilter {
660 keys: vec!["C major".to_string()],
661 key_mode: KeyFilterMode::Exact,
662 ..Default::default()
663 };
664 let results = search_in_folder(&db, &filter, vfs_id, None).unwrap();
665 assert_eq!(results.len(), 1);
666 assert_eq!(results[0].node.name, "c.wav");
667 }
668 }
669