Skip to main content

ipfrs_storage/
metadata_index.rs

1//! Secondary index over block metadata fields for efficient filtered queries.
2//!
3//! [`StorageMetadataIndex`] maintains an in-memory inverted index keyed by
4//! [`MetadataField`] variants (tags, content-type, owner, size bucket, tick
5//! bucket). Queries combine AND / NOT constraints without scanning every stored
6//! block entry.
7
8use std::collections::{HashMap, HashSet};
9
10// ─────────────────────────────────────────────────────────────────────────────
11// MetadataField
12// ─────────────────────────────────────────────────────────────────────────────
13
14/// Discriminated metadata field used as an index key.
15#[derive(Clone, Debug, PartialEq, Eq, Hash)]
16pub enum MetadataField {
17    /// Arbitrary tag string (e.g. `"important"`, `"cold-storage"`).
18    Tag(String),
19    /// MIME-style content-type string (e.g. `"image/png"`).
20    ContentType(String),
21    /// Peer-ID of the content owner.
22    Owner(String),
23    /// Quantised size bucket: `block_size / 65536` (integer division).
24    SizeBucket(u64),
25    /// Quantised tick bucket: `created_at_tick / 1000` (integer division).
26    TickBucket(u64),
27}
28
29// ─────────────────────────────────────────────────────────────────────────────
30// IndexEntry (aliased as MetadataIndexEntry in pub use)
31// ─────────────────────────────────────────────────────────────────────────────
32
33/// Metadata record stored for each block in the metadata index.
34#[derive(Clone, Debug, PartialEq, Eq)]
35pub struct MetadataIndexEntry {
36    /// Numeric block identifier (primary key).
37    pub block_id: u64,
38    /// Content identifier string (CID).
39    pub cid: String,
40    /// All metadata fields associated with this block.
41    pub fields: Vec<MetadataField>,
42}
43
44// ─────────────────────────────────────────────────────────────────────────────
45// SortField
46// ─────────────────────────────────────────────────────────────────────────────
47
48/// Sort dimension for [`MetadataQuery`] results.
49#[derive(Clone, Copy, Debug, PartialEq, Eq)]
50pub enum MetadataSortField {
51    /// Sort ascending by `block_id`.
52    BlockId,
53    /// Sort alphabetically ascending by `cid`.
54    Cid,
55}
56
57// ─────────────────────────────────────────────────────────────────────────────
58// MetadataQuery
59// ─────────────────────────────────────────────────────────────────────────────
60
61/// A structured query against the metadata index.
62#[derive(Clone, Debug, Default)]
63pub struct MetadataQuery {
64    /// All of these fields must be present on the block (AND semantics).
65    pub must_have: Vec<MetadataField>,
66    /// None of these fields may be present on the block (NOT semantics).
67    pub must_not_have: Vec<MetadataField>,
68    /// Optional sort dimension applied before `limit`.
69    pub sort_by: Option<MetadataSortField>,
70    /// Optional maximum number of results to return.
71    pub limit: Option<usize>,
72}
73
74// ─────────────────────────────────────────────────────────────────────────────
75// QueryResult (aliased as MetadataQueryResult in pub use)
76// ─────────────────────────────────────────────────────────────────────────────
77
78/// A single result entry returned by [`StorageMetadataIndex::query`].
79#[derive(Clone, Debug, PartialEq, Eq)]
80pub struct MetadataQueryResult {
81    /// Numeric block identifier.
82    pub block_id: u64,
83    /// Content identifier string (CID).
84    pub cid: String,
85    /// The subset of `must_have` fields that matched on this block.
86    pub matched_fields: Vec<MetadataField>,
87}
88
89// ─────────────────────────────────────────────────────────────────────────────
90// MetadataIndexStats
91// ─────────────────────────────────────────────────────────────────────────────
92
93/// Cumulative statistics for a [`StorageMetadataIndex`].
94#[derive(Clone, Debug, Default, PartialEq, Eq)]
95pub struct MetadataIndexStats {
96    /// Total number of entries currently held.
97    pub total_entries: usize,
98    /// Sum of `fields.len()` across all entries.
99    pub total_fields_indexed: usize,
100    /// Total number of times [`StorageMetadataIndex::query`] has been called.
101    pub total_queries: u64,
102}
103
104// ─────────────────────────────────────────────────────────────────────────────
105// StorageMetadataIndex
106// ─────────────────────────────────────────────────────────────────────────────
107
108/// Secondary index over block metadata fields enabling efficient filtered queries.
109///
110/// # Design
111///
112/// Two data structures are maintained in tandem:
113///
114/// * `entries` — a `HashMap<block_id, MetadataIndexEntry>` for O(1) point
115///   lookups and to support fast replacement when the same block is re-inserted.
116/// * `field_index` — an inverted index mapping each `MetadataField` to the set
117///   of `block_id`s that carry that field.
118///
119/// Queries intersect the posting lists of all `must_have` fields (giving the
120/// candidate set), then subtract any block whose entry contains a `must_not_have`
121/// field.
122pub struct StorageMetadataIndex {
123    /// Primary store: block_id → entry.
124    pub entries: HashMap<u64, MetadataIndexEntry>,
125    /// Inverted index: field → block_ids.
126    pub field_index: HashMap<MetadataField, Vec<u64>>,
127    /// Running statistics.
128    pub stats: MetadataIndexStats,
129}
130
131impl StorageMetadataIndex {
132    // ── Construction ──────────────────────────────────────────────────────────
133
134    /// Create an empty [`StorageMetadataIndex`].
135    pub fn new() -> Self {
136        Self {
137            entries: HashMap::new(),
138            field_index: HashMap::new(),
139            stats: MetadataIndexStats::default(),
140        }
141    }
142
143    // ── Mutation ──────────────────────────────────────────────────────────────
144
145    /// Insert (or replace) a block entry.
146    ///
147    /// If a block with the same `block_id` already exists its old field index
148    /// postings are removed before the new entry is written.
149    pub fn insert(&mut self, entry: MetadataIndexEntry) {
150        // Remove old postings if the block already exists.
151        if let Some(old) = self.entries.remove(&entry.block_id) {
152            self.stats.total_entries -= 1;
153            self.stats.total_fields_indexed -= old.fields.len();
154            for field in &old.fields {
155                if let Some(ids) = self.field_index.get_mut(field) {
156                    ids.retain(|&id| id != old.block_id);
157                    if ids.is_empty() {
158                        self.field_index.remove(field);
159                    }
160                }
161            }
162        }
163
164        // Build new postings.
165        let block_id = entry.block_id;
166        let field_count = entry.fields.len();
167        for field in &entry.fields {
168            self.field_index
169                .entry(field.clone())
170                .or_default()
171                .push(block_id);
172        }
173
174        self.entries.insert(block_id, entry);
175        self.stats.total_entries += 1;
176        self.stats.total_fields_indexed += field_count;
177    }
178
179    /// Remove the entry for `block_id`.
180    ///
181    /// Returns `true` if an entry was present and removed, `false` otherwise.
182    pub fn remove(&mut self, block_id: u64) -> bool {
183        match self.entries.remove(&block_id) {
184            None => false,
185            Some(old) => {
186                self.stats.total_entries -= 1;
187                self.stats.total_fields_indexed -= old.fields.len();
188                for field in &old.fields {
189                    if let Some(ids) = self.field_index.get_mut(field) {
190                        ids.retain(|&id| id != block_id);
191                        if ids.is_empty() {
192                            self.field_index.remove(field);
193                        }
194                    }
195                }
196                true
197            }
198        }
199    }
200
201    // ── Query ─────────────────────────────────────────────────────────────────
202
203    /// Execute a structured metadata query.
204    ///
205    /// # Algorithm
206    ///
207    /// 1. **Candidate set** – if `must_have` is empty, all block IDs are
208    ///    candidates; otherwise the candidate set is the intersection of the
209    ///    posting lists for each `must_have` field.
210    /// 2. **Exclusion** – remove any candidate whose entry contains *any*
211    ///    `must_not_have` field.
212    /// 3. **Projection** – build a [`MetadataQueryResult`] per surviving
213    ///    candidate recording which `must_have` fields matched.
214    /// 4. **Sort** – apply `sort_by` if present.
215    /// 5. **Limit** – truncate to `limit` if present.
216    /// 6. **Stats** – increment `total_queries`.
217    pub fn query(&mut self, q: &MetadataQuery) -> Vec<MetadataQueryResult> {
218        // Step 1: compute candidate block_id set.
219        let candidates: HashSet<u64> = if q.must_have.is_empty() {
220            self.entries.keys().copied().collect()
221        } else {
222            // Start from the posting list of the first must_have field,
223            // then intersect with each subsequent field's posting list.
224            let mut iter = q.must_have.iter();
225            // Safety: must_have is non-empty, so first() always returns Some.
226            let first_field = iter.next().expect("must_have checked non-empty");
227            let initial: HashSet<u64> = self
228                .field_index
229                .get(first_field)
230                .map(|v| v.iter().copied().collect())
231                .unwrap_or_default();
232
233            iter.fold(initial, |acc, field| {
234                let posting: HashSet<u64> = self
235                    .field_index
236                    .get(field)
237                    .map(|v| v.iter().copied().collect())
238                    .unwrap_or_default();
239                acc.intersection(&posting).copied().collect()
240            })
241        };
242
243        // Step 2: build must_not_have field set for O(1) membership checks.
244        let exclude_set: HashSet<&MetadataField> = q.must_not_have.iter().collect();
245
246        // Step 3: filter and project.
247        let mut results: Vec<MetadataQueryResult> = candidates
248            .into_iter()
249            .filter_map(|block_id| {
250                let entry = self.entries.get(&block_id)?;
251                // Exclude if any entry field is in the exclusion set.
252                let excluded = entry.fields.iter().any(|f| exclude_set.contains(f));
253                if excluded {
254                    return None;
255                }
256                // Collect which must_have fields are actually present.
257                let matched_fields: Vec<MetadataField> = q
258                    .must_have
259                    .iter()
260                    .filter(|f| entry.fields.contains(f))
261                    .cloned()
262                    .collect();
263                Some(MetadataQueryResult {
264                    block_id: entry.block_id,
265                    cid: entry.cid.clone(),
266                    matched_fields,
267                })
268            })
269            .collect();
270
271        // Step 4: sort.
272        match q.sort_by {
273            Some(MetadataSortField::BlockId) => {
274                results.sort_by_key(|r| r.block_id);
275            }
276            Some(MetadataSortField::Cid) => {
277                results.sort_by(|a, b| a.cid.cmp(&b.cid));
278            }
279            None => {}
280        }
281
282        // Step 5: apply limit.
283        if let Some(limit) = q.limit {
284            results.truncate(limit);
285        }
286
287        // Step 6: update stats.
288        self.stats.total_queries += 1;
289
290        results
291    }
292
293    // ── Point lookup ──────────────────────────────────────────────────────────
294
295    /// Return a reference to the entry for `block_id`, or `None`.
296    pub fn get(&self, block_id: u64) -> Option<&MetadataIndexEntry> {
297        self.entries.get(&block_id)
298    }
299
300    /// Return a reference to the current statistics snapshot.
301    pub fn stats(&self) -> &MetadataIndexStats {
302        &self.stats
303    }
304}
305
306impl Default for StorageMetadataIndex {
307    fn default() -> Self {
308        Self::new()
309    }
310}
311
312// ─────────────────────────────────────────────────────────────────────────────
313// Tests
314// ─────────────────────────────────────────────────────────────────────────────
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    // ── helpers ───────────────────────────────────────────────────────────────
321
322    fn make_entry(block_id: u64, cid: &str, fields: Vec<MetadataField>) -> MetadataIndexEntry {
323        MetadataIndexEntry {
324            block_id,
325            cid: cid.to_string(),
326            fields,
327        }
328    }
329
330    fn png_entry(block_id: u64) -> MetadataIndexEntry {
331        make_entry(
332            block_id,
333            &format!("cid-{block_id}"),
334            vec![
335                MetadataField::ContentType("image/png".to_string()),
336                MetadataField::Tag("photo".to_string()),
337                MetadataField::Owner("alice".to_string()),
338                MetadataField::SizeBucket(block_id % 4),
339                MetadataField::TickBucket(block_id / 5),
340            ],
341        )
342    }
343
344    // ── insert ────────────────────────────────────────────────────────────────
345
346    #[test]
347    fn test_insert_creates_entry() {
348        let mut idx = StorageMetadataIndex::new();
349        idx.insert(png_entry(1));
350        assert!(idx.get(1).is_some());
351        assert_eq!(idx.stats().total_entries, 1);
352    }
353
354    #[test]
355    fn test_insert_updates_field_index() {
356        let mut idx = StorageMetadataIndex::new();
357        idx.insert(png_entry(1));
358        let key = MetadataField::ContentType("image/png".to_string());
359        assert!(idx.field_index.contains_key(&key));
360        assert!(idx.field_index[&key].contains(&1));
361    }
362
363    #[test]
364    fn test_insert_multiple_entries_share_field() {
365        let mut idx = StorageMetadataIndex::new();
366        idx.insert(png_entry(1));
367        idx.insert(png_entry(2));
368        let key = MetadataField::ContentType("image/png".to_string());
369        let ids = &idx.field_index[&key];
370        assert!(ids.contains(&1));
371        assert!(ids.contains(&2));
372    }
373
374    #[test]
375    fn test_insert_replaces_existing_entry() {
376        let mut idx = StorageMetadataIndex::new();
377        idx.insert(make_entry(
378            10,
379            "cid-old",
380            vec![MetadataField::Tag("old-tag".to_string())],
381        ));
382        // Replace with new data.
383        idx.insert(make_entry(
384            10,
385            "cid-new",
386            vec![MetadataField::Tag("new-tag".to_string())],
387        ));
388        assert_eq!(idx.stats().total_entries, 1);
389        assert_eq!(idx.get(10).unwrap().cid, "cid-new");
390        // Old field must be gone from field_index.
391        let old_key = MetadataField::Tag("old-tag".to_string());
392        assert!(!idx.field_index.contains_key(&old_key));
393        // New field must exist.
394        let new_key = MetadataField::Tag("new-tag".to_string());
395        assert!(idx.field_index.contains_key(&new_key));
396    }
397
398    #[test]
399    fn test_insert_replace_updates_stats_correctly() {
400        let mut idx = StorageMetadataIndex::new();
401        idx.insert(make_entry(
402            5,
403            "cid-5",
404            vec![
405                MetadataField::Tag("a".to_string()),
406                MetadataField::Tag("b".to_string()),
407            ],
408        ));
409        assert_eq!(idx.stats().total_fields_indexed, 2);
410        // Replace with 3 fields.
411        idx.insert(make_entry(
412            5,
413            "cid-5",
414            vec![
415                MetadataField::Tag("x".to_string()),
416                MetadataField::Tag("y".to_string()),
417                MetadataField::Tag("z".to_string()),
418            ],
419        ));
420        assert_eq!(idx.stats().total_entries, 1);
421        assert_eq!(idx.stats().total_fields_indexed, 3);
422    }
423
424    // ── remove ────────────────────────────────────────────────────────────────
425
426    #[test]
427    fn test_remove_returns_true_when_present() {
428        let mut idx = StorageMetadataIndex::new();
429        idx.insert(png_entry(1));
430        assert!(idx.remove(1));
431    }
432
433    #[test]
434    fn test_remove_returns_false_when_absent() {
435        let mut idx = StorageMetadataIndex::new();
436        assert!(!idx.remove(999));
437    }
438
439    #[test]
440    fn test_remove_cleans_field_index() {
441        let mut idx = StorageMetadataIndex::new();
442        idx.insert(png_entry(1));
443        idx.remove(1);
444        let key = MetadataField::ContentType("image/png".to_string());
445        assert!(!idx.field_index.contains_key(&key));
446    }
447
448    #[test]
449    fn test_remove_partial_field_index_cleanup() {
450        let mut idx = StorageMetadataIndex::new();
451        idx.insert(png_entry(1));
452        idx.insert(png_entry(2));
453        idx.remove(1);
454        let key = MetadataField::ContentType("image/png".to_string());
455        // Field still present because block 2 has it.
456        assert!(idx.field_index.contains_key(&key));
457        assert!(!idx.field_index[&key].contains(&1));
458        assert!(idx.field_index[&key].contains(&2));
459    }
460
461    #[test]
462    fn test_remove_updates_stats() {
463        let mut idx = StorageMetadataIndex::new();
464        idx.insert(png_entry(1));
465        let initial_fields = idx.stats().total_fields_indexed;
466        idx.remove(1);
467        assert_eq!(idx.stats().total_entries, 0);
468        assert_eq!(idx.stats().total_fields_indexed, 0);
469        assert!(initial_fields > 0);
470    }
471
472    // ── get ───────────────────────────────────────────────────────────────────
473
474    #[test]
475    fn test_get_some() {
476        let mut idx = StorageMetadataIndex::new();
477        idx.insert(png_entry(42));
478        assert!(idx.get(42).is_some());
479        assert_eq!(idx.get(42).unwrap().cid, "cid-42");
480    }
481
482    #[test]
483    fn test_get_none() {
484        let idx = StorageMetadataIndex::new();
485        assert!(idx.get(0).is_none());
486    }
487
488    // ── query: must_have (single field) ──────────────────────────────────────
489
490    #[test]
491    fn test_query_must_have_single_field_matches() {
492        let mut idx = StorageMetadataIndex::new();
493        idx.insert(png_entry(1));
494        idx.insert(png_entry(2));
495        idx.insert(make_entry(
496            3,
497            "cid-3",
498            vec![MetadataField::ContentType("text/plain".to_string())],
499        ));
500
501        let q = MetadataQuery {
502            must_have: vec![MetadataField::ContentType("image/png".to_string())],
503            must_not_have: vec![],
504            sort_by: Some(MetadataSortField::BlockId),
505            limit: None,
506        };
507        let results = idx.query(&q);
508        assert_eq!(results.len(), 2);
509        assert_eq!(results[0].block_id, 1);
510        assert_eq!(results[1].block_id, 2);
511    }
512
513    #[test]
514    fn test_query_must_have_single_field_no_match() {
515        let mut idx = StorageMetadataIndex::new();
516        idx.insert(png_entry(1));
517        let q = MetadataQuery {
518            must_have: vec![MetadataField::Tag("nonexistent".to_string())],
519            ..Default::default()
520        };
521        let results = idx.query(&q);
522        assert!(results.is_empty());
523    }
524
525    // ── query: must_have (multiple fields / AND) ──────────────────────────────
526
527    #[test]
528    fn test_query_must_have_multiple_fields_and_semantics() {
529        let mut idx = StorageMetadataIndex::new();
530        // block 1: has both png AND photo
531        idx.insert(png_entry(1));
532        // block 2: has png but NOT photo
533        idx.insert(make_entry(
534            2,
535            "cid-2",
536            vec![MetadataField::ContentType("image/png".to_string())],
537        ));
538
539        let q = MetadataQuery {
540            must_have: vec![
541                MetadataField::ContentType("image/png".to_string()),
542                MetadataField::Tag("photo".to_string()),
543            ],
544            ..Default::default()
545        };
546        let results = idx.query(&q);
547        assert_eq!(results.len(), 1);
548        assert_eq!(results[0].block_id, 1);
549    }
550
551    #[test]
552    fn test_query_must_have_matched_fields_populated() {
553        let mut idx = StorageMetadataIndex::new();
554        idx.insert(png_entry(1));
555        let q = MetadataQuery {
556            must_have: vec![
557                MetadataField::ContentType("image/png".to_string()),
558                MetadataField::Tag("photo".to_string()),
559            ],
560            sort_by: Some(MetadataSortField::BlockId),
561            ..Default::default()
562        };
563        let results = idx.query(&q);
564        assert_eq!(results.len(), 1);
565        assert!(results[0]
566            .matched_fields
567            .contains(&MetadataField::ContentType("image/png".to_string())));
568        assert!(results[0]
569            .matched_fields
570            .contains(&MetadataField::Tag("photo".to_string())));
571    }
572
573    // ── query: must_not_have ──────────────────────────────────────────────────
574
575    #[test]
576    fn test_query_must_not_have_excludes_entries() {
577        let mut idx = StorageMetadataIndex::new();
578        idx.insert(png_entry(1)); // has Owner("alice")
579        idx.insert(make_entry(
580            2,
581            "cid-2",
582            vec![
583                MetadataField::ContentType("image/png".to_string()),
584                MetadataField::Owner("bob".to_string()),
585            ],
586        ));
587
588        let q = MetadataQuery {
589            must_have: vec![MetadataField::ContentType("image/png".to_string())],
590            must_not_have: vec![MetadataField::Owner("alice".to_string())],
591            sort_by: Some(MetadataSortField::BlockId),
592            limit: None,
593        };
594        let results = idx.query(&q);
595        assert_eq!(results.len(), 1);
596        assert_eq!(results[0].block_id, 2);
597    }
598
599    #[test]
600    fn test_query_must_not_have_excludes_all() {
601        let mut idx = StorageMetadataIndex::new();
602        idx.insert(png_entry(1));
603        let q = MetadataQuery {
604            must_have: vec![MetadataField::ContentType("image/png".to_string())],
605            must_not_have: vec![MetadataField::Tag("photo".to_string())],
606            ..Default::default()
607        };
608        let results = idx.query(&q);
609        assert!(results.is_empty());
610    }
611
612    // ── query: sort ───────────────────────────────────────────────────────────
613
614    #[test]
615    fn test_query_sort_by_block_id_ascending() {
616        let mut idx = StorageMetadataIndex::new();
617        for id in [5u64, 3, 1, 4, 2] {
618            idx.insert(make_entry(
619                id,
620                &format!("cid-{id}"),
621                vec![MetadataField::Tag("common".to_string())],
622            ));
623        }
624        let q = MetadataQuery {
625            must_have: vec![MetadataField::Tag("common".to_string())],
626            sort_by: Some(MetadataSortField::BlockId),
627            ..Default::default()
628        };
629        let results = idx.query(&q);
630        let ids: Vec<u64> = results.iter().map(|r| r.block_id).collect();
631        assert_eq!(ids, vec![1, 2, 3, 4, 5]);
632    }
633
634    #[test]
635    fn test_query_sort_by_cid_alphabetical_ascending() {
636        let mut idx = StorageMetadataIndex::new();
637        let entries = vec![
638            make_entry(1, "zed", vec![MetadataField::Tag("t".to_string())]),
639            make_entry(2, "alpha", vec![MetadataField::Tag("t".to_string())]),
640            make_entry(3, "mango", vec![MetadataField::Tag("t".to_string())]),
641        ];
642        for e in entries {
643            idx.insert(e);
644        }
645        let q = MetadataQuery {
646            must_have: vec![MetadataField::Tag("t".to_string())],
647            sort_by: Some(MetadataSortField::Cid),
648            ..Default::default()
649        };
650        let results = idx.query(&q);
651        let cids: Vec<&str> = results.iter().map(|r| r.cid.as_str()).collect();
652        assert_eq!(cids, vec!["alpha", "mango", "zed"]);
653    }
654
655    // ── query: limit ──────────────────────────────────────────────────────────
656
657    #[test]
658    fn test_query_limit_truncates_results() {
659        let mut idx = StorageMetadataIndex::new();
660        for id in 1..=10u64 {
661            idx.insert(make_entry(
662                id,
663                &format!("cid-{id}"),
664                vec![MetadataField::Tag("bulk".to_string())],
665            ));
666        }
667        let q = MetadataQuery {
668            must_have: vec![MetadataField::Tag("bulk".to_string())],
669            sort_by: Some(MetadataSortField::BlockId),
670            limit: Some(3),
671            ..Default::default()
672        };
673        let results = idx.query(&q);
674        assert_eq!(results.len(), 3);
675        // First three by block_id.
676        assert_eq!(results[0].block_id, 1);
677        assert_eq!(results[1].block_id, 2);
678        assert_eq!(results[2].block_id, 3);
679    }
680
681    #[test]
682    fn test_query_limit_zero_returns_empty() {
683        let mut idx = StorageMetadataIndex::new();
684        idx.insert(png_entry(1));
685        let q = MetadataQuery {
686            must_have: vec![MetadataField::ContentType("image/png".to_string())],
687            limit: Some(0),
688            ..Default::default()
689        };
690        let results = idx.query(&q);
691        assert!(results.is_empty());
692    }
693
694    // ── query: empty must_have returns all ────────────────────────────────────
695
696    #[test]
697    fn test_query_empty_must_have_returns_all() {
698        let mut idx = StorageMetadataIndex::new();
699        idx.insert(png_entry(1));
700        idx.insert(png_entry(2));
701        idx.insert(make_entry(
702            3,
703            "cid-3",
704            vec![MetadataField::Owner("eve".to_string())],
705        ));
706
707        let q = MetadataQuery {
708            must_have: vec![],
709            sort_by: Some(MetadataSortField::BlockId),
710            ..Default::default()
711        };
712        let results = idx.query(&q);
713        assert_eq!(results.len(), 3);
714    }
715
716    #[test]
717    fn test_query_empty_must_have_matched_fields_is_empty() {
718        let mut idx = StorageMetadataIndex::new();
719        idx.insert(png_entry(1));
720        let q = MetadataQuery::default();
721        let results = idx.query(&q);
722        assert_eq!(results.len(), 1);
723        assert!(results[0].matched_fields.is_empty());
724    }
725
726    // ── stats ─────────────────────────────────────────────────────────────────
727
728    #[test]
729    fn test_stats_total_fields_indexed() {
730        let mut idx = StorageMetadataIndex::new();
731        idx.insert(make_entry(
732            1,
733            "a",
734            vec![
735                MetadataField::Tag("x".to_string()),
736                MetadataField::SizeBucket(1),
737            ],
738        ));
739        idx.insert(make_entry(
740            2,
741            "b",
742            vec![MetadataField::Tag("y".to_string())],
743        ));
744        assert_eq!(idx.stats().total_fields_indexed, 3);
745    }
746
747    #[test]
748    fn test_stats_total_queries_increments() {
749        let mut idx = StorageMetadataIndex::new();
750        idx.insert(png_entry(1));
751        let q = MetadataQuery::default();
752        idx.query(&q);
753        idx.query(&q);
754        idx.query(&q);
755        assert_eq!(idx.stats().total_queries, 3);
756    }
757
758    #[test]
759    fn test_stats_total_entries_after_operations() {
760        let mut idx = StorageMetadataIndex::new();
761        idx.insert(png_entry(1));
762        idx.insert(png_entry(2));
763        idx.insert(png_entry(3));
764        idx.remove(2);
765        assert_eq!(idx.stats().total_entries, 2);
766    }
767
768    // ── owner / size / tick bucket variants ──────────────────────────────────
769
770    #[test]
771    fn test_query_owner_field() {
772        let mut idx = StorageMetadataIndex::new();
773        idx.insert(make_entry(
774            1,
775            "c1",
776            vec![MetadataField::Owner("alice".to_string())],
777        ));
778        idx.insert(make_entry(
779            2,
780            "c2",
781            vec![MetadataField::Owner("bob".to_string())],
782        ));
783        let q = MetadataQuery {
784            must_have: vec![MetadataField::Owner("alice".to_string())],
785            ..Default::default()
786        };
787        let results = idx.query(&q);
788        assert_eq!(results.len(), 1);
789        assert_eq!(results[0].block_id, 1);
790    }
791
792    #[test]
793    fn test_query_size_bucket_field() {
794        let mut idx = StorageMetadataIndex::new();
795        idx.insert(make_entry(1, "c1", vec![MetadataField::SizeBucket(0)]));
796        idx.insert(make_entry(2, "c2", vec![MetadataField::SizeBucket(1)]));
797        idx.insert(make_entry(3, "c3", vec![MetadataField::SizeBucket(0)]));
798        let q = MetadataQuery {
799            must_have: vec![MetadataField::SizeBucket(0)],
800            sort_by: Some(MetadataSortField::BlockId),
801            ..Default::default()
802        };
803        let results = idx.query(&q);
804        assert_eq!(results.len(), 2);
805        assert_eq!(results[0].block_id, 1);
806        assert_eq!(results[1].block_id, 3);
807    }
808
809    #[test]
810    fn test_query_tick_bucket_field() {
811        let mut idx = StorageMetadataIndex::new();
812        idx.insert(make_entry(1, "c1", vec![MetadataField::TickBucket(0)]));
813        idx.insert(make_entry(2, "c2", vec![MetadataField::TickBucket(1)]));
814        let q = MetadataQuery {
815            must_have: vec![MetadataField::TickBucket(1)],
816            ..Default::default()
817        };
818        let results = idx.query(&q);
819        assert_eq!(results.len(), 1);
820        assert_eq!(results[0].block_id, 2);
821    }
822
823    // ── edge cases ────────────────────────────────────────────────────────────
824
825    #[test]
826    fn test_query_on_empty_index() {
827        let mut idx = StorageMetadataIndex::new();
828        let q = MetadataQuery::default();
829        let results = idx.query(&q);
830        assert!(results.is_empty());
831    }
832
833    #[test]
834    fn test_insert_entry_with_no_fields() {
835        let mut idx = StorageMetadataIndex::new();
836        idx.insert(make_entry(1, "c1", vec![]));
837        assert_eq!(idx.stats().total_entries, 1);
838        assert_eq!(idx.stats().total_fields_indexed, 0);
839        // Empty must_have should still return it.
840        let q = MetadataQuery {
841            sort_by: Some(MetadataSortField::BlockId),
842            ..Default::default()
843        };
844        let results = idx.query(&q);
845        assert_eq!(results.len(), 1);
846    }
847
848    #[test]
849    fn test_default_impl() {
850        let idx = StorageMetadataIndex::default();
851        assert_eq!(idx.stats().total_entries, 0);
852    }
853}