db.rs (26043B)
1 use anyhow::{Context, Result}; 2 use regex::Regex; 3 use serde::{Deserialize, Serialize}; 4 use sled::Tree; 5 use std::cmp::Reverse; 6 use std::fmt; 7 use std::path::Path; 8 9 #[derive(Debug, Serialize, Deserialize)] 10 pub struct TrackReport { 11 pub artist: Option<String>, 12 pub track: Option<String>, 13 pub bpm: Option<f64>, 14 pub key: Option<String>, 15 #[serde(default)] 16 pub timestamp: Option<i64>, 17 } 18 19 impl fmt::Display for TrackReport { 20 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 21 let bpm = self 22 .bpm 23 .as_ref() 24 .map_or_else(|| "-".to_string(), |b| format!("{b:.1}")); 25 let key = self.key.as_deref().unwrap_or("-"); 26 let artist = self.artist.as_deref().unwrap_or("Unknown"); 27 let track = self.track.as_deref().unwrap_or("Unknown"); 28 write!(f, "{artist} - {track} | BPM: {bpm} | Key: {key}") 29 } 30 } 31 32 /// Sled-backed library database with secondary indexes for artist, key, and BPM. 33 /// 34 /// Schema: 35 /// - default tree: `"Artist - Track"` → postcard-encoded `TrackReport` 36 /// - `"artist"` tree: `"artist\0primary_key"` → 8-byte BE timestamp 37 /// - `"key"` tree: `"key\0primary_key"` → 8-byte BE timestamp 38 /// - `"bpm"` tree: `f64_be_bytes + "\0" + primary_key` → 8-byte BE timestamp 39 /// - `"time"` tree: `i64_be_bytes(timestamp) + "\0" + primary_key` → empty 40 pub struct LibraryDb { 41 db: sled::Db, 42 artist_tree: Tree, 43 key_tree: Tree, 44 bpm_tree: Tree, 45 time_tree: Tree, 46 } 47 48 const SEP: u8 = 0x00; 49 const BPM_KEY_LEN: usize = 8; // f64 big-endian 50 const TIME_KEY_LEN: usize = 8; // i64 big-endian 51 52 fn prefix_index_key(value: &[u8], primary_key: &[u8]) -> Vec<u8> { 53 let mut buf = Vec::with_capacity(value.len() + 1 + primary_key.len()); 54 buf.extend_from_slice(value); 55 buf.push(SEP); 56 buf.extend_from_slice(primary_key); 57 buf 58 } 59 60 fn bpm_index_key(bpm: f64, primary_key: &[u8]) -> Vec<u8> { 61 let mut buf = Vec::with_capacity(BPM_KEY_LEN + 1 + primary_key.len()); 62 buf.extend_from_slice(&bpm.to_be_bytes()); 63 buf.push(SEP); 64 buf.extend_from_slice(primary_key); 65 buf 66 } 67 68 fn time_index_key(timestamp: i64, primary_key: &[u8]) -> Vec<u8> { 69 let mut buf = Vec::with_capacity(TIME_KEY_LEN + 1 + primary_key.len()); 70 buf.extend_from_slice(×tamp.to_be_bytes()); 71 buf.push(SEP); 72 buf.extend_from_slice(primary_key); 73 buf 74 } 75 76 fn extract_primary_key(idx_key: &[u8]) -> Result<&[u8]> { 77 let pos = idx_key 78 .iter() 79 .position(|&b| b == SEP) 80 .context("Malformed index key: no separator found")?; 81 Ok(&idx_key[pos + 1..]) 82 } 83 84 fn extract_primary_key_string(idx_key: &[u8]) -> Result<String> { 85 let bytes = extract_primary_key(idx_key)?; 86 String::from_utf8(bytes.to_vec()).context("Invalid UTF-8 in primary key") 87 } 88 89 fn parse_timestamp(value: &[u8]) -> i64 { 90 if value.len() == 8 { 91 i64::from_be_bytes(value[..8].try_into().unwrap_or([0; 8])) 92 } else { 93 0 94 } 95 } 96 97 fn serialize_report(report: &TrackReport) -> Result<Vec<u8>> { 98 postcard::to_allocvec(report).context("Failed to serialize track report") 99 } 100 101 fn deserialize_report(bytes: &[u8]) -> Result<TrackReport> { 102 postcard::from_bytes(bytes).context("Failed to deserialize track report") 103 } 104 105 fn effective_limit(limit: usize) -> usize { 106 if limit == 0 { usize::MAX } else { limit } 107 } 108 109 impl LibraryDb { 110 /// Open or create the database at the given path. 111 /// 112 /// # Errors 113 /// 114 /// Returns an error if the database cannot be opened or created. 115 pub fn open(path: &Path) -> Result<Self> { 116 let db = sled::open(path) 117 .with_context(|| format!("Failed to open database at {}", path.display()))?; 118 let artist_tree = db 119 .open_tree("artist") 120 .context("Failed to open artist tree")?; 121 let key_tree = db.open_tree("key").context("Failed to open key tree")?; 122 let bpm_tree = db.open_tree("bpm").context("Failed to open bpm tree")?; 123 let time_tree = db.open_tree("time").context("Failed to open time tree")?; 124 Ok(Self { 125 db, 126 artist_tree, 127 key_tree, 128 bpm_tree, 129 time_tree, 130 }) 131 } 132 133 /// Write secondary index entries for a report. 134 fn update_indexes(&self, key: &str, report: &TrackReport) -> Result<()> { 135 let ts = report.timestamp.unwrap_or(0); 136 let ts_bytes = ts.to_be_bytes(); 137 138 // Artist index 139 let artist_lower = report.artist.as_deref().unwrap_or("unknown").to_lowercase(); 140 let idx_key = prefix_index_key(artist_lower.as_bytes(), key.as_bytes()); 141 self.artist_tree.insert(&idx_key, &ts_bytes[..])?; 142 143 // Key-signature index 144 let key_lower = report.key.as_deref().unwrap_or("unknown").to_lowercase(); 145 let idx_key = prefix_index_key(key_lower.as_bytes(), key.as_bytes()); 146 self.key_tree.insert(&idx_key, &ts_bytes[..])?; 147 148 // BPM index 149 if let Some(bpm) = report.bpm { 150 let idx_key = bpm_index_key(bpm, key.as_bytes()); 151 self.bpm_tree.insert(&idx_key, &ts_bytes[..])?; 152 } 153 154 // Time index (always written, timestamp=0 for entries without one) 155 let idx_key = time_index_key(ts, key.as_bytes()); 156 self.time_tree.insert(&idx_key, &[][..])?; 157 158 Ok(()) 159 } 160 161 /// Remove all secondary index entries for a report. 162 fn remove_indexes(&self, key: &str, report: &TrackReport) -> Result<()> { 163 let artist_lower = report.artist.as_deref().unwrap_or("unknown").to_lowercase(); 164 let idx_key = prefix_index_key(artist_lower.as_bytes(), key.as_bytes()); 165 self.artist_tree.remove(&idx_key)?; 166 167 let key_lower = report.key.as_deref().unwrap_or("unknown").to_lowercase(); 168 let idx_key = prefix_index_key(key_lower.as_bytes(), key.as_bytes()); 169 self.key_tree.remove(&idx_key)?; 170 171 if let Some(bpm) = report.bpm { 172 let idx_key = bpm_index_key(bpm, key.as_bytes()); 173 self.bpm_tree.remove(&idx_key)?; 174 } 175 176 let ts = report.timestamp.unwrap_or(0); 177 let idx_key = time_index_key(ts, key.as_bytes()); 178 self.time_tree.remove(&idx_key)?; 179 180 Ok(()) 181 } 182 183 /// Get a track report by exact primary key. 184 /// 185 /// # Errors 186 /// 187 /// Returns an error if the database operation fails. 188 pub fn get(&self, key: &str) -> Result<Option<TrackReport>> { 189 self.db 190 .get(key.as_bytes())? 191 .map(|bytes| deserialize_report(&bytes)) 192 .transpose() 193 } 194 195 /// Save a single track report, inserting or updating as needed. 196 /// 197 /// # Errors 198 /// 199 /// Returns an error if the database operation fails. 200 pub fn save(&self, key: &str, report: &TrackReport) -> Result<()> { 201 // Remove stale indexes if overwriting an existing entry. 202 if let Some(old_bytes) = self 203 .db 204 .insert(key.as_bytes(), serialize_report(report)?.as_slice())? 205 { 206 let old_report = deserialize_report(&old_bytes)?; 207 self.remove_indexes(key, &old_report)?; 208 } 209 210 self.update_indexes(key, report)?; 211 Ok(()) 212 } 213 214 /// Save a batch of track reports, inserting or updating as needed. 215 /// 216 /// Uses sled's batch API for the default tree to reduce write amplification. 217 /// 218 /// # Errors 219 /// 220 /// Returns an error if the database operation fails. 221 pub fn save_batch(&self, entries: &[(String, TrackReport)]) -> Result<()> { 222 for (key, report) in entries { 223 // Remove stale indexes for any existing entries. 224 if let Some(old_bytes) = self.db.get(key.as_bytes())? { 225 let old_report = deserialize_report(&old_bytes)?; 226 self.remove_indexes(key, &old_report)?; 227 } 228 self.update_indexes(key, report)?; 229 } 230 231 let mut batch = sled::Batch::default(); 232 for (key, report) in entries { 233 let serialized = serialize_report(report)?; 234 batch.insert(key.as_bytes(), serialized.as_slice()); 235 } 236 self.db.apply_batch(batch)?; 237 Ok(()) 238 } 239 240 /// Delete a track report and its secondary index entries. 241 /// 242 /// # Errors 243 /// 244 /// Returns an error if the database operation fails. 245 pub fn delete(&self, key: &str) -> Result<()> { 246 if let Some(old_bytes) = self.db.remove(key.as_bytes())? { 247 let old_report = deserialize_report(&old_bytes)?; 248 self.remove_indexes(key, &old_report)?; 249 } 250 Ok(()) 251 } 252 253 /// List up to `limit` track reports (pass `0` for no limit), 254 /// ordered chronologically (oldest first, most recent at the bottom). 255 /// 256 /// # Errors 257 /// 258 /// Returns an error if the database operation fails. 259 pub fn list(&self, limit: usize) -> Result<Vec<(String, TrackReport)>> { 260 let mut candidates: Vec<(String, i64)> = Vec::new(); 261 262 for item in &self.time_tree { 263 let (idx_key, _) = item.context("Failed to read time index")?; 264 let primary_key_bytes = &idx_key[TIME_KEY_LEN + 1..]; 265 let primary_key = String::from_utf8(primary_key_bytes.to_vec()) 266 .context("Invalid UTF-8 in primary key")?; 267 let ts = i64::from_be_bytes(idx_key[..TIME_KEY_LEN].try_into().unwrap_or([0; 8])); 268 candidates.push((primary_key, ts)); 269 } 270 271 self.resolve_candidates(candidates, limit) 272 } 273 274 /// Count the total number of entries in the database. 275 /// 276 /// # Errors 277 /// 278 /// Returns an error if the database operation fails. 279 pub fn count(&self) -> Result<usize> { 280 Ok(self.db.len()?) 281 } 282 283 /// Resolve a list of (`primary_key`, timestamp) candidates into report entries. 284 /// 285 /// Sorts by timestamp descending, truncates to `limit`, and looks up each 286 /// primary key in the default tree. Returns results in reverse (oldest-first) 287 /// order so that most recently added entries appear at the bottom. 288 fn resolve_candidates( 289 &self, 290 mut candidates: Vec<(String, i64)>, 291 limit: usize, 292 ) -> Result<Vec<(String, TrackReport)>> { 293 let lim = effective_limit(limit); 294 candidates.sort_by_key(|b| Reverse(b.1)); 295 candidates.truncate(lim); 296 297 let mut results = Vec::with_capacity(candidates.len()); 298 for (primary_key, _) in candidates { 299 if let Some(report) = self.get(&primary_key)? { 300 results.push((primary_key, report)); 301 } 302 } 303 results.reverse(); 304 Ok(results) 305 } 306 307 /// Find tracks by artist name prefix (case-insensitive), up to `limit` items. 308 /// 309 /// # Errors 310 /// 311 /// Returns an error if the database operation fails. 312 pub fn find_by_artist(&self, prefix: &str, limit: usize) -> Result<Vec<(String, TrackReport)>> { 313 let prefix_lower = prefix.to_lowercase(); 314 let mut candidates: Vec<(String, i64)> = Vec::new(); 315 316 for item in self.artist_tree.scan_prefix(prefix_lower.as_bytes()) { 317 let (idx_key, value) = item.context("Failed to read artist index")?; 318 let primary_key = extract_primary_key_string(&idx_key)?; 319 let ts = parse_timestamp(&value); 320 candidates.push((primary_key, ts)); 321 } 322 323 self.resolve_candidates(candidates, limit) 324 } 325 326 /// Find tracks by key signature prefix (case-insensitive), up to `limit` items. 327 /// 328 /// # Errors 329 /// 330 /// Returns an error if the database operation fails. 331 pub fn find_by_key(&self, prefix: &str, limit: usize) -> Result<Vec<(String, TrackReport)>> { 332 let prefix_lower = prefix.to_lowercase(); 333 let mut candidates: Vec<(String, i64)> = Vec::new(); 334 335 for item in self.key_tree.scan_prefix(prefix_lower.as_bytes()) { 336 let (idx_key, value) = item.context("Failed to read key index")?; 337 let primary_key = extract_primary_key_string(&idx_key)?; 338 let ts = parse_timestamp(&value); 339 candidates.push((primary_key, ts)); 340 } 341 342 self.resolve_candidates(candidates, limit) 343 } 344 345 /// Find tracks with BPM in the given range (inclusive), up to `limit` items. 346 /// 347 /// # Errors 348 /// 349 /// Returns an error if the database operation fails. 350 pub fn find_by_bpm_range( 351 &self, 352 min: f64, 353 max: f64, 354 limit: usize, 355 ) -> Result<Vec<(String, TrackReport)>> { 356 let mut candidates: Vec<(String, i64)> = Vec::new(); 357 358 // Sled sorts longer keys after their prefix, so a bare 8-byte bound would 359 // exclude keys like [max_be, 0x00, primary_key]. 360 let mut upper = Vec::with_capacity(BPM_KEY_LEN + 1); 361 upper.extend_from_slice(&max.to_be_bytes()); 362 upper.push(0xFF); 363 364 for item in self 365 .bpm_tree 366 .range(min.to_be_bytes().as_slice()..upper.as_slice()) 367 { 368 let (idx_key, value) = item.context("Failed to read bpm index")?; 369 let primary_key_bytes = &idx_key[BPM_KEY_LEN + 1..]; 370 let primary_key = String::from_utf8(primary_key_bytes.to_vec()) 371 .context("Invalid UTF-8 in primary key")?; 372 let ts = parse_timestamp(&value); 373 candidates.push((primary_key, ts)); 374 } 375 376 self.resolve_candidates(candidates, limit) 377 } 378 379 /// Search tracks by regex pattern. 380 /// 381 /// Checks the primary key first (cheap, no deserialization), then 382 /// falls back to checking individual report fields. Returns up to 383 /// `limit` matches (pass `0` for no limit). 384 /// 385 /// # Errors 386 /// 387 /// Returns an error if the database operation fails. 388 pub fn search(&self, pattern: &Regex, limit: usize) -> Result<Vec<(String, TrackReport)>> { 389 let lim = effective_limit(limit); 390 let mut results = Vec::new(); 391 392 for item in &self.db { 393 let (key, value) = item.context("Failed to read entry")?; 394 let key_str = String::from_utf8(key.to_vec()).context("Invalid UTF-8 key")?; 395 396 let report = deserialize_report(&value)?; 397 398 // Check the primary key and individual fields — avoids 399 // allocating a display String for every non-matching entry. 400 let matches = pattern.is_match(&key_str) 401 || report 402 .artist 403 .as_deref() 404 .is_some_and(|a| pattern.is_match(a)) 405 || report.track.as_deref().is_some_and(|t| pattern.is_match(t)) 406 || report.key.as_deref().is_some_and(|k| pattern.is_match(k)) 407 || report 408 .bpm 409 .is_some_and(|b| pattern.is_match(&format!("{b:.1}"))); 410 411 if matches { 412 results.push((key_str, report)); 413 if results.len() >= lim { 414 break; 415 } 416 } 417 } 418 419 Ok(results) 420 } 421 422 /// Flush pending writes to disk. 423 /// 424 /// # Errors 425 /// 426 /// Returns an error if the database operation fails. 427 pub fn flush(&self) -> Result<()> { 428 self.db.flush()?; 429 Ok(()) 430 } 431 } 432 433 #[cfg(test)] 434 mod tests { 435 use super::*; 436 437 // ---- Pure helper tests ---- 438 439 #[test] 440 fn prefix_index_key_joins_with_separator() { 441 assert_eq!(prefix_index_key(b"artist", b"primary"), b"artist\0primary"); 442 } 443 444 #[test] 445 fn extract_primary_key_reads_after_separator() { 446 let key = prefix_index_key(b"aphex twin", b"Aphex Twin - Xtal"); 447 assert_eq!(extract_primary_key(&key).unwrap(), b"Aphex Twin - Xtal"); 448 assert_eq!( 449 extract_primary_key_string(&key).unwrap(), 450 "Aphex Twin - Xtal" 451 ); 452 } 453 454 #[test] 455 fn extract_primary_key_errors_without_separator() { 456 assert!(extract_primary_key(b"no-separator-here").is_err()); 457 } 458 459 #[test] 460 fn bpm_index_key_is_sorted_by_value() { 461 // Big-endian f64 bytes preserve ordering for non-negative values, so the 462 // raw index keys sort the same way as the BPM values they encode. 463 let slow = bpm_index_key(90.0, b"k"); 464 let fast = bpm_index_key(140.0, b"k"); 465 assert!(slow < fast); 466 assert_eq!(&slow[..BPM_KEY_LEN], &90.0f64.to_be_bytes()); 467 assert_eq!(slow[BPM_KEY_LEN], SEP); 468 } 469 470 #[test] 471 fn time_index_key_layout() { 472 let k = time_index_key(42, b"primary"); 473 assert_eq!(&k[..TIME_KEY_LEN], &42i64.to_be_bytes()); 474 assert_eq!(k[TIME_KEY_LEN], SEP); 475 assert_eq!(&k[TIME_KEY_LEN + 1..], b"primary"); 476 } 477 478 #[test] 479 fn parse_timestamp_handles_good_and_bad_lengths() { 480 assert_eq!(parse_timestamp(&42i64.to_be_bytes()), 42); 481 assert_eq!(parse_timestamp(&[]), 0); 482 assert_eq!(parse_timestamp(&[1, 2, 3]), 0); 483 } 484 485 #[test] 486 fn effective_limit_treats_zero_as_unbounded() { 487 assert_eq!(effective_limit(0), usize::MAX); 488 assert_eq!(effective_limit(7), 7); 489 } 490 491 #[test] 492 fn serialize_roundtrip_preserves_fields() { 493 let report = TrackReport { 494 artist: Some("Aphex Twin".to_string()), 495 track: Some("Xtal".to_string()), 496 bpm: Some(120.5), 497 key: Some("A Minor".to_string()), 498 timestamp: Some(1700), 499 }; 500 let bytes = serialize_report(&report).unwrap(); 501 let back = deserialize_report(&bytes).unwrap(); 502 assert_eq!(back.artist, report.artist); 503 assert_eq!(back.track, report.track); 504 assert_eq!(back.bpm, report.bpm); 505 assert_eq!(back.key, report.key); 506 assert_eq!(back.timestamp, report.timestamp); 507 } 508 509 #[test] 510 fn display_formats_full_and_empty_reports() { 511 let full = TrackReport { 512 artist: Some("Aphex Twin".to_string()), 513 track: Some("Xtal".to_string()), 514 bpm: Some(120.04), 515 key: Some("A Minor".to_string()), 516 timestamp: Some(1700), 517 }; 518 assert_eq!(full.to_string(), "Aphex Twin - Xtal | BPM: 120.0 | Key: A Minor"); 519 520 let empty = TrackReport { 521 artist: None, 522 track: None, 523 bpm: None, 524 key: None, 525 timestamp: None, 526 }; 527 assert_eq!(empty.to_string(), "Unknown - Unknown | BPM: - | Key: -"); 528 } 529 530 // ---- Integration tests against a real sled database ---- 531 532 fn report(artist: &str, track: &str, bpm: f64, key: &str, ts: i64) -> (String, TrackReport) { 533 ( 534 format!("{artist} - {track}"), 535 TrackReport { 536 artist: Some(artist.to_string()), 537 track: Some(track.to_string()), 538 bpm: Some(bpm), 539 key: Some(key.to_string()), 540 timestamp: Some(ts), 541 }, 542 ) 543 } 544 545 fn temp_db() -> (tempfile::TempDir, LibraryDb) { 546 let dir = tempfile::tempdir().unwrap(); 547 let db = LibraryDb::open(&dir.path().join("db")).unwrap(); 548 (dir, db) 549 } 550 551 #[test] 552 fn save_and_get_roundtrip() { 553 let (_dir, db) = temp_db(); 554 let (key, rep) = report("Aphex Twin", "Xtal", 120.0, "A Minor", 1); 555 db.save(&key, &rep).unwrap(); 556 557 let got = db.get(&key).unwrap().unwrap(); 558 assert_eq!(got.artist.as_deref(), Some("Aphex Twin")); 559 assert_eq!(got.bpm, Some(120.0)); 560 assert!(db.get("missing").unwrap().is_none()); 561 assert_eq!(db.count().unwrap(), 1); 562 } 563 564 #[test] 565 fn find_by_artist_is_prefix_and_case_insensitive() { 566 let (_dir, db) = temp_db(); 567 let (k1, r1) = report("Aphex Twin", "Xtal", 120.0, "A Minor", 1); 568 let (k2, r2) = report("Aphex Twin", "Ageispolis", 110.0, "C Major", 2); 569 let (k3, r3) = report("Boards of Canada", "Roygbiv", 95.0, "C Major", 3); 570 db.save(&k1, &r1).unwrap(); 571 db.save(&k2, &r2).unwrap(); 572 db.save(&k3, &r3).unwrap(); 573 574 let hits = db.find_by_artist("aphex", 0).unwrap(); 575 assert_eq!(hits.len(), 2); 576 assert!(hits.iter().all(|(_, r)| r.artist.as_deref() == Some("Aphex Twin"))); 577 578 assert_eq!(db.find_by_artist("APHEX TWIN", 0).unwrap().len(), 2); 579 assert!(db.find_by_artist("nobody", 0).unwrap().is_empty()); 580 } 581 582 #[test] 583 fn find_by_key_is_prefix_and_case_insensitive() { 584 let (_dir, db) = temp_db(); 585 let (k1, r1) = report("A", "x", 120.0, "C Major", 1); 586 let (k2, r2) = report("B", "y", 110.0, "C Minor", 2); 587 let (k3, r3) = report("C", "z", 95.0, "D Major", 3); 588 db.save(&k1, &r1).unwrap(); 589 db.save(&k2, &r2).unwrap(); 590 db.save(&k3, &r3).unwrap(); 591 592 assert_eq!(db.find_by_key("c ", 0).unwrap().len(), 2); 593 assert_eq!(db.find_by_key("c major", 0).unwrap().len(), 1); 594 assert_eq!(db.find_by_key("D", 0).unwrap().len(), 1); 595 } 596 597 #[test] 598 fn find_by_bpm_range_is_inclusive() { 599 let (_dir, db) = temp_db(); 600 for (i, bpm) in [90.0, 120.0, 140.0, 160.0].into_iter().enumerate() { 601 let (k, r) = report("artist", &format!("t{i}"), bpm, "C Major", i as i64); 602 db.save(&k, &r).unwrap(); 603 } 604 605 let hits = db.find_by_bpm_range(120.0, 140.0, 0).unwrap(); 606 let mut bpms: Vec<f64> = hits.iter().map(|(_, r)| r.bpm.unwrap()).collect(); 607 bpms.sort_by(f64::total_cmp); 608 assert_eq!(bpms, vec![120.0, 140.0]); 609 610 // Bounds are inclusive on both ends. 611 assert_eq!(db.find_by_bpm_range(90.0, 160.0, 0).unwrap().len(), 4); 612 assert!(db.find_by_bpm_range(200.0, 300.0, 0).unwrap().is_empty()); 613 } 614 615 #[test] 616 fn list_orders_oldest_last_and_respects_limit() { 617 let (_dir, db) = temp_db(); 618 let (k1, r1) = report("A", "old", 100.0, "C Major", 10); 619 let (k2, r2) = report("B", "mid", 100.0, "C Major", 20); 620 let (k3, r3) = report("C", "new", 100.0, "C Major", 30); 621 db.save(&k1, &r1).unwrap(); 622 db.save(&k2, &r2).unwrap(); 623 db.save(&k3, &r3).unwrap(); 624 625 // Full listing is chronological: oldest first, newest at the bottom. 626 let all = db.list(0).unwrap(); 627 let keys: Vec<&str> = all.iter().map(|(k, _)| k.as_str()).collect(); 628 assert_eq!(keys, vec!["A - old", "B - mid", "C - new"]); 629 630 // A limit keeps the most recent N, still shown oldest-first. 631 let limited = db.list(2).unwrap(); 632 let keys: Vec<&str> = limited.iter().map(|(k, _)| k.as_str()).collect(); 633 assert_eq!(keys, vec!["B - mid", "C - new"]); 634 } 635 636 #[test] 637 fn save_overwrites_and_refreshes_indexes() { 638 let (_dir, db) = temp_db(); 639 let key = "Artist - Track".to_string(); 640 let first = TrackReport { 641 artist: Some("Artist".to_string()), 642 track: Some("Track".to_string()), 643 bpm: Some(120.0), 644 key: Some("C Major".to_string()), 645 timestamp: Some(1), 646 }; 647 db.save(&key, &first).unwrap(); 648 649 // Overwrite with a different key signature and BPM. 650 let second = TrackReport { 651 artist: Some("Artist".to_string()), 652 track: Some("Track".to_string()), 653 bpm: Some(128.0), 654 key: Some("A Minor".to_string()), 655 timestamp: Some(2), 656 }; 657 db.save(&key, &second).unwrap(); 658 659 assert_eq!(db.count().unwrap(), 1); 660 // Stale indexes are gone; only the new ones resolve. 661 assert!(db.find_by_key("c major", 0).unwrap().is_empty()); 662 assert_eq!(db.find_by_key("a minor", 0).unwrap().len(), 1); 663 assert!(db.find_by_bpm_range(119.0, 121.0, 0).unwrap().is_empty()); 664 assert_eq!(db.find_by_bpm_range(127.0, 129.0, 0).unwrap().len(), 1); 665 } 666 667 #[test] 668 fn delete_removes_entry_and_indexes() { 669 let (_dir, db) = temp_db(); 670 let (key, rep) = report("Aphex Twin", "Xtal", 120.0, "A Minor", 1); 671 db.save(&key, &rep).unwrap(); 672 db.delete(&key).unwrap(); 673 674 assert!(db.get(&key).unwrap().is_none()); 675 assert_eq!(db.count().unwrap(), 0); 676 assert!(db.find_by_artist("aphex", 0).unwrap().is_empty()); 677 assert!(db.find_by_key("a minor", 0).unwrap().is_empty()); 678 assert!(db.find_by_bpm_range(119.0, 121.0, 0).unwrap().is_empty()); 679 assert!(db.list(0).unwrap().is_empty()); 680 681 // Deleting a missing key is a no-op. 682 db.delete("does not exist").unwrap(); 683 } 684 685 #[test] 686 fn save_batch_inserts_all_and_refreshes_stale_indexes() { 687 let (_dir, db) = temp_db(); 688 let (k, r) = report("Artist", "Track", 100.0, "C Major", 1); 689 db.save(&k, &r).unwrap(); 690 691 let batch = vec![ 692 report("Artist", "Track", 128.0, "A Minor", 5), // overwrites k 693 report("Other", "Song", 90.0, "G Major", 6), 694 ]; 695 db.save_batch(&batch).unwrap(); 696 697 assert_eq!(db.count().unwrap(), 2); 698 assert!(db.find_by_key("c major", 0).unwrap().is_empty()); 699 assert_eq!(db.find_by_key("a minor", 0).unwrap().len(), 1); 700 assert_eq!(db.get("Artist - Track").unwrap().unwrap().bpm, Some(128.0)); 701 } 702 703 #[test] 704 fn search_matches_key_artist_track_and_bpm() { 705 let (_dir, db) = temp_db(); 706 let (k1, r1) = report("Aphex Twin", "Xtal", 120.0, "A Minor", 1); 707 let (k2, r2) = report("Boards of Canada", "Roygbiv", 95.5, "C Major", 2); 708 db.save(&k1, &r1).unwrap(); 709 db.save(&k2, &r2).unwrap(); 710 711 // Match on the primary key / artist. 712 assert_eq!(db.search(&Regex::new("^Aphex").unwrap(), 0).unwrap().len(), 1); 713 // Match on track title. 714 assert_eq!(db.search(&Regex::new("Roygbiv").unwrap(), 0).unwrap().len(), 1); 715 // Match on formatted BPM (one decimal place). 716 assert_eq!(db.search(&Regex::new("95.5").unwrap(), 0).unwrap().len(), 1); 717 // No match. 718 assert!(db.search(&Regex::new("zzz").unwrap(), 0).unwrap().is_empty()); 719 720 // Limit caps the number of results. 721 let both = db.search(&Regex::new(".").unwrap(), 1).unwrap(); 722 assert_eq!(both.len(), 1); 723 } 724 }