virittaa

audio metadata analyzer
Log | Files | Refs | README | LICENSE

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(&timestamp.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 }